Compare commits

...
36 Commits
Author SHA1 Message Date
Anthony Stirling 38ea7d0cbc wordings 2025-12-19 18:20:32 +00:00
Anthony Stirling 2c747e70c7 other fixes 2025-12-17 00:35:02 +00:00
Anthony Stirling cb22c4c651 translations 2025-12-16 22:23:50 +00:00
Anthony Stirling f723aa8abe lint and type 2025-12-16 22:18:09 +00:00
Anthony Stirling 1beed8dc57 photo scan 2025-12-16 22:10:04 +00:00
Anthony Stirling 63550e0c7d Merge remote-tracking branch 'origin/main' into ExtraSamlFixes 2025-12-16 16:26:29 +00:00
Anthony Stirling a2091b7851 Merge remote-tracking branch 'origin/main' into ExtraSamlFixes 2025-12-16 16:25:47 +00:00
Reece Browne 93ed05b054 Only allow dual page view when there is more than one page, update to… (#5246) 2025-12-16 16:02:22 +00:00
Reece BrowneandClaude Sonnet 4.5 195b1472e4 Bug/v2/viewer annotations (#5245)
Show uneditable annotations on viewer
show editable annotations layer when in annotation tools (sign, add
image, add text)
Remove draw tool from viewer (this is replaced wholesale in an upcoming
PR so it wasn't worth doing the work to ensure it worked with the new
annotation layer set up_)
refactoring work, mostly renaming variables we can use for all
annotation based tools that had sign specific names.
remove "tools" tooltip

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 15:52:47 +00:00
James Brunton 340006ceea Add Sign Up functionality to desktop app (#5244)
# Description of Changes
Adds Sign Up with email to desktop app. SSO sign up will come in a
future PR.
2025-12-16 14:55:53 +00:00
Anthony Stirling d80e627899 Cache fix issues V2 (#5237)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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)

### 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.
2025-12-15 23:54:25 +00:00
Anthony Stirling 43137cde83 tests 2025-12-15 23:34:28 +00:00
Anthony Stirling 9ea2c94912 tests 2025-12-15 23:27:59 +00:00
Anthony Stirling ad33679c2b fix grandfather logs 2025-12-15 23:06:13 +00:00
Anthony Stirling c390f21114 minor stuff 2025-12-15 22:29:19 +00:00
Anthony Stirling b03ecef121 version bump 2025-12-15 22:15:32 +00:00
Anthony Stirling 3d9d2621fa fixes 2025-12-15 22:10:38 +00:00
Anthony Stirling 9c84e1eed4 Merge remote-tracking branch 'origin/main' into pdfCache 2025-12-15 21:58:35 +00:00
Anthony Stirling e424af1a31 several SAML 2025-12-15 21:57:59 +00:00
Anthony Stirling 336ec34125 V2 Handle SSO account restrictions in account settings (#5225)
## Summary
- hide password and username update controls for SSO accounts and show a
managed-account notice
- prevent account update handlers from calling APIs when the user
authenticates via SSO
- expose authenticationType on the user session model and add
translations for new SSO messaging

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693ae8144148832888ecf128e66cd3ca)
2025-12-15 21:55:58 +00:00
Anthony Stirling 71d416ce90 Merge branch 'main' into pdfCache 2025-12-15 15:35:40 +00:00
Anthony StirlingandJames Brunton 5f72c05623 line art (#5052)
## Summary
- introduce a shared line art conversion interface and proprietary
ImageMagick-backed implementation
- have the compress controller optionally autowire the enterprise
service before running per-image line art processing
- remove ImageMagick command details from core by delegating conversions
through the proprietary service

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e)

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-15 11:14:10 +00:00
Anthony Stirling 33188815da Remove UserApi mapping from proprietary signature controller (#5239)
## Summary
- remove the UserApi composite annotation from the proprietary signature
controller to avoid duplicate request mappings

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693af8cc8210832890f4787bae07d11f)
2025-12-15 10:43:36 +00:00
EthanHealy01andJames Brunton 0064c1866e Shorten onbaording (#5198)
Also added `enableDesktopInstallSlide` flag in `settings.yml` to hide
the download for desktop page in the onboarding.

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-12-15 09:31:41 +00:00
Anthony Stirling 371d816ce7 Validate certificate inputs for cert signing (#5191)
## Summary
- validate required certificate inputs before loading keystores to
prevent null dereferences
- surface clear errors for missing PEM, PKCS12/PFX, and JKS uploads
during PDF signing
- add a unit test covering the missing PKCS12/PFX keystore scenario

## Testing
- ./gradlew :stirling-pdf:test --tests
stirling.software.SPDF.controller.api.security.CertSignControllerTest

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934c8803d648328bf76b72a4f689c60)
2025-12-13 17:42:12 +00:00
Anthony Stirling 5f54308d2b Simplify V2 PR auto-deploy triggers (#5236)
## Summary
- deploy V2 environments for authorized PR authors without requiring
title or branch keywords
- update deployment messaging to reflect the new trigger policy

## Testing
- not run


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693c9ac446d88328b8ae04609caec787)
2025-12-13 16:51:16 +00:00
Anthony Stirling 91cdd20b3f remove stuff 2025-12-13 16:48:35 +00:00
Anthony Stirling 235e68fa2b lint :) 2025-12-13 16:28:27 +00:00
Anthony Stirling 3279b28a76 fixes and cleanups 2025-12-13 13:56:24 +00:00
Reece Browne 6f7b8ce433 Bug/v2/static wasm (#5238)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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)

### 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.
2025-12-13 12:57:10 +00:00
James Brunton 69ffd29bb5 Fix German text for sign tool text entry (#5232)
# Description of Changes
Fix #5206

Required splitting out the logic for the text with font entry so that
the labels are configurable from the call-site instead of all using the
same ones for Sign.
2025-12-12 18:00:40 +00:00
Reece Browne f4cc87144d Fix language codes in picker (#5233)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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)

### 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.
2025-12-12 17:44:15 +00:00
Anthony Stirling c86e2d6840 Delete .github/README.md 2025-12-11 16:38:32 +00:00
Anthony Stirling 5f072f87bb Account change details (#5190)
## Summary
Accounts setting page to change a users password or username
Fix huge bug were users can see admin settings due to hard code
admin=true
## Testing
- not run (not requested)

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b8ecdbf08328a0951b46db77dfd2)
2025-12-11 13:56:35 +00:00
Anthony Stirling d95abcea95 missed commit 2025-12-10 23:26:46 +00:00
Anthony Stirling a73636a597 Cache fix issues 2025-12-10 23:26:35 +00:00
138 changed files with 7415 additions and 4067 deletions
-12
View File
@@ -1,12 +0,0 @@
# CI Configuration
## CI Lite Mode
Skip non-essential CI workflows by setting a repository variable:
**Settings → Secrets and variables → Actions → Variables → New repository variable**
- Name: `CI_PROFILE`
- Value: `lite`
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
+3 -14
View File
@@ -52,7 +52,6 @@ jobs:
core.setOutput('repository', pr.head.repo.full_name);
core.setOutput('ref', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.fork));
core.setOutput('base_ref', pr.base.ref);
core.setOutput('author', pr.user.login);
core.setOutput('state', pr.state);
@@ -65,10 +64,6 @@ jobs:
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
# nur bei workflow_dispatch gesetzt:
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
# für Auto-PR-Logik:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
run: |
set -e
@@ -89,14 +84,8 @@ jobs:
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
if [ "$is_auth" = true ]; then
should=true
else
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
should=true
fi
fi
fi
@@ -174,7 +163,7 @@ jobs:
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
@@ -394,7 +383,7 @@ jobs:
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
`🔄 **Auto-deployed** for approved V2 contributors.`;
await github.rest.issues.createComment({
owner,
+2
View File
@@ -43,6 +43,8 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/vendor/
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
@@ -491,6 +491,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
/* ImageMagick */
addEndpointToGroup("ImageMagick", "compress-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -574,6 +577,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group)
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group);
}
@@ -37,10 +37,6 @@ public class AppConfig {
private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -49,6 +45,17 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}")
public boolean v2Enabled;
@@ -68,6 +68,7 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -100,6 +101,46 @@ public class ApplicationProperties {
private String outputFolder;
}
@Data
public static class PdfEditor {
private Cache cache = new Cache();
private FontNormalization fontNormalization = new FontNormalization();
private CffConverter cffConverter = new CffConverter();
private Type3 type3 = new Type3();
private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
@Data
public static class Cache {
private long maxBytes = -1;
private int maxPercent = 20;
}
@Data
public static class FontNormalization {
private boolean enabled = false;
}
@Data
public static class CffConverter {
private boolean enabled = true;
private String method = "python";
private String pythonCommand = "/opt/venv/bin/python3";
private String pythonScript = "/scripts/convert_cff_to_ttf.py";
private String fontforgeCommand = "fontforge";
}
@Data
public static class Type3 {
private Library library = new Library();
@Data
public static class Library {
private boolean enabled = true;
private String index = "classpath:/type3/library/index.json";
}
}
}
@Data
public static class Legal {
private String termsAndConditions;
@@ -357,6 +398,7 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
@@ -367,10 +409,12 @@ public class ApplicationProperties {
private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup();
private List<String> corsAllowedOrigins = new ArrayList<>();
private String
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
// 'http://localhost:8080', 'https://api.example.com'). Required for
// SSO.
private String frontendUrl; // Frontend URL for invite email links (e.g.
// falls back to backend URL.
// 'https://app.example.com'). If not set, falls back to backendUrl.
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -535,6 +579,7 @@ public class ApplicationProperties {
@ToString.Exclude private String key;
private String UUID;
private String appVersion;
private Boolean isNewServer;
}
// TODO: Remove post migration
@@ -652,6 +697,7 @@ public class ApplicationProperties {
private int weasyPrintSessionLimit;
private int installAppSessionLimit;
private int calibreSessionLimit;
private int imageMagickSessionLimit;
private int qpdfSessionLimit;
private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
@@ -689,6 +735,10 @@ public class ApplicationProperties {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
}
public int getImageMagickSessionLimit() {
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
}
public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
}
@@ -718,6 +768,8 @@ public class ApplicationProperties {
@JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes;
private long imageMagickTimeoutMinutes;
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
@@ -755,6 +807,10 @@ public class ApplicationProperties {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
}
public long getImageMagickTimeoutMinutes() {
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
}
public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
}
@@ -0,0 +1,12 @@
package stirling.software.common.service;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
public interface LineArtConversionService {
PDImageXObject convertImageToLineArt(
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
throws IOException;
}
@@ -0,0 +1,291 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@Slf4j
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
/**
* Stores uploaded files for a session
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Create session directory
Path sessionDir = tempDirectory.resolve(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "upload-" + System.currentTimeMillis();
}
// Sanitize filename
String safeFilename = sanitizeFilename(originalFilename);
Path filePath = sessionDir.resolve(safeFilename);
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename);
counter++;
}
file.transferTo(filePath);
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
log.info(
"Uploaded file for session {}: {} ({} bytes)",
sessionId,
safeFilename,
file.getSize());
}
session.updateLastAccess();
}
/**
* Retrieves file metadata for a session
*
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
/**
* Retrieves actual file data for download
*
* @param sessionId Session identifier
* @param filename Filename to retrieve
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filename);
}
session.updateLastAccess();
session.markFileAsDownloaded(filename);
return filePath;
}
/**
* Deletes a file after it has been served to the client. Should be called after successful
* download.
*
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
Files.deleteIfExists(filePath);
log.info("Deleted file after download: {}/{}", sessionId, filename);
// Check if all files have been downloaded - if so, delete the entire session
SessionData session = activeSessions.get(sessionId);
if (session != null && session.allFilesDownloaded()) {
deleteSession(sessionId);
log.info("All files downloaded - deleted session: {}", sessionId);
}
} catch (IOException e) {
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
}
}
/**
* Deletes a session and all its files
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
Path sessionDir = tempDirectory.resolve(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
log.info("Deleted session: {}", sessionId);
} catch (IOException e) {
log.error("Error deleting session directory: {}", sessionId, e);
}
}
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
activeSessions.forEach(
(sessionId, session) -> {
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
expiredSessions.add(sessionId);
}
});
if (!expiredSessions.isEmpty()) {
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
expiredSessions.forEach(this::deleteSession);
}
}
private void validateSessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
return filename.replaceAll("[^a-zA-Z0-9._-]", "_");
}
/** File metadata for client */
public static class FileMetadata {
private final String filename;
private final long size;
private final String contentType;
public FileMetadata(String filename, long size, String contentType) {
this.filename = filename;
this.size = size;
this.contentType = contentType;
}
public String getFilename() {
return filename;
}
public long getSize() {
return size;
}
public String getContentType() {
return contentType;
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
private final List<FileMetadata> files = new ArrayList<>();
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
public SessionData(String sessionId) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.lastAccessTime = createdAt;
}
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
}
public List<FileMetadata> getFiles() {
return files;
}
public void markFileAsDownloaded(String filename) {
downloadedFiles.put(filename, true);
}
public boolean allFilesDownloaded() {
return !downloadedFiles.isEmpty()
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
}
public void updateLastAccess() {
this.lastAccessTime = System.currentTimeMillis();
}
public long getLastAccessTime() {
return lastAccessTime;
}
}
}
@@ -86,6 +86,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getImageMagickSessionLimit();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -141,6 +146,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getImageMagickTimeoutMinutes();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -301,6 +311,7 @@ public class ProcessExecutor {
WEASYPRINT,
INSTALL_APP,
CALIBRE,
IMAGEMAGICK,
TESSERACT,
QPDF,
GHOSTSCRIPT,
@@ -26,6 +26,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
@@ -51,6 +52,11 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -61,7 +67,8 @@ public class RequestUriUtils {
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
|| normalizedUri.endsWith(".toml")
|| normalizedUri.endsWith(".wasm");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
@@ -125,11 +132,13 @@ public class RequestUriUtils {
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.endsWith(".wasm")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
|| requestURI.startsWith("/pdfjs")
|| requestURI.startsWith("/pdfium"));
}
/**
@@ -164,6 +173,8 @@ public class RequestUriUtils {
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
}
@@ -24,6 +24,9 @@ public class RequestUriUtilsTest {
assertTrue(
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
"PDF.js files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
"PDFium wasm should be static");
assertTrue(
RequestUriUtils.isStaticResource("/api/v1/info/status"),
"API status should be static");
@@ -110,7 +113,8 @@ public class RequestUriUtilsTest {
"/downloads/document.png",
"/assets/brand.ico",
"/any/path/with/image.svg",
"/deep/nested/folder/icon.png"
"/deep/nested/folder/icon.png",
"/pdfium/pdfium.wasm"
})
void testIsStaticResourceWithFileExtensions(String path) {
assertTrue(
@@ -148,6 +152,9 @@ public class RequestUriUtilsTest {
assertFalse(
RequestUriUtils.isTrackableResource("/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
"PDFium wasm should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/swagger/index.html"),
"Swagger files should not be trackable");
@@ -224,7 +231,8 @@ public class RequestUriUtilsTest {
"/api/v1/info/health",
"/site.webmanifest",
"/fonts/roboto.woff",
"/pdfjs/viewer.js"
"/pdfjs/viewer.js",
"/pdfium/pdfium.wasm"
})
void testNonTrackableResources(String path) {
assertFalse(
@@ -138,13 +138,13 @@ public class SPDFApplication {
@PostConstruct
public void init() {
String baseUrl = appConfig.getBaseUrl();
String backendUrl = appConfig.getBackendUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = baseUrl;
baseUrlStatic = backendUrl;
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
String url = backendUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"principal",
"startDate",
"endDate",
"async");
"async",
"session");
@Override
public boolean preHandle(
@@ -46,6 +46,7 @@ public class ExternalAppDepConfig {
put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract"));
put("rar", List.of("rar")); // Required for real CBR output
put("magick", List.of("ImageMagick"));
}
};
}
@@ -128,6 +129,7 @@ public class ExternalAppDepConfig {
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar");
checkDependencyAndDisableGroup("magick");
// Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) {
@@ -94,6 +94,7 @@ public class InitialSetup {
}
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
}
public static boolean isNewServer() {
@@ -0,0 +1,60 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.exception.CacheUnavailableException;
@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
@Slf4j
@RequiredArgsConstructor
public class ConvertPdfJsonExceptionHandler {
private final ObjectMapper objectMapper;
@ExceptionHandler(CacheUnavailableException.class)
@ResponseBody
public ResponseEntity<byte[]> handleCacheUnavailable(CacheUnavailableException ex) {
try {
byte[] body =
objectMapper.writeValueAsBytes(
java.util.Map.of(
"error", "cache_unavailable",
"action", "reupload",
"message", ex.getMessage()));
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(body);
} catch (Exception e) {
log.warn("Failed to serialize cache_unavailable response", e);
var fallbackBody =
java.util.Map.of(
"error", "cache_unavailable",
"action", "reupload",
"message", String.valueOf(ex.getMessage()));
try {
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(objectMapper.writeValueAsBytes(fallbackBody));
} catch (Exception ignored) {
// Truly last-ditch fallback
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(
"{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}"
.getBytes(StandardCharsets.UTF_8));
}
}
}
}
@@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -58,6 +62,9 @@ public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
@Autowired(required = false)
private LineArtConversionService lineArtConversionService;
private boolean isQpdfEnabled() {
return endpointConfiguration.isGroupEnabled("qpdf");
}
@@ -66,6 +73,10 @@ public class CompressController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
private boolean isImageMagickEnabled() {
return endpointConfiguration.isGroupEnabled("ImageMagick");
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@@ -660,6 +671,9 @@ public class CompressController {
Integer optimizeLevel = request.getOptimizeLevel();
String expectedOutputSizeString = request.getExpectedOutputSize();
Boolean convertToGrayscale = request.getGrayscale();
Boolean convertToLineArt = request.getLineArt();
Double lineArtThreshold = request.getLineArtThreshold();
Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
if (expectedOutputSizeString == null && optimizeLevel == null) {
throw new Exception("Both expected output size and optimize level are not specified");
}
@@ -689,6 +703,26 @@ public class CompressController {
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
}
if (Boolean.TRUE.equals(convertToLineArt)) {
if (lineArtConversionService == null) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Line art conversion is unavailable - ImageMagick service not found");
}
if (!isImageMagickEnabled()) {
throw new IOException(
"ImageMagick is not enabled but line art conversion was requested");
}
double thresholdValue =
lineArtThreshold == null
? 55d
: Math.min(100d, Math.max(0d, lineArtThreshold));
int edgeLevel =
lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel));
currentFile =
applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel);
}
boolean sizeMet = false;
boolean imageCompressionApplied = false;
boolean externalCompressionApplied = false;
@@ -810,6 +844,75 @@ public class CompressController {
}
}
private Path applyLineArtConversion(
Path currentFile, List<Path> tempFiles, double threshold, int edgeLevel)
throws IOException {
Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf");
tempFiles.add(lineArtFile);
try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) {
Map<String, List<ImageReference>> uniqueImages = findImages(doc);
CompressionStats stats = new CompressionStats();
stats.uniqueImagesCount = uniqueImages.size();
calculateImageStats(uniqueImages, stats);
Map<String, PDImageXObject> convertedImages =
createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel);
replaceImages(doc, uniqueImages, convertedImages, stats);
log.info(
"Applied line art conversion to {} unique images ({} total references)",
stats.uniqueImagesCount,
stats.totalImages);
doc.save(lineArtFile.toString());
return lineArtFile;
}
}
private Map<String, PDImageXObject> createLineArtImages(
PDDocument doc,
Map<String, List<ImageReference>> uniqueImages,
CompressionStats stats,
double threshold,
int edgeLevel)
throws IOException {
Map<String, PDImageXObject> convertedImages = new HashMap<>();
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
String imageHash = entry.getKey();
List<ImageReference> references = entry.getValue();
if (references.isEmpty()) continue;
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
int originalSize = (int) originalImage.getCOSObject().getLength();
stats.totalOriginalBytes += originalSize;
PDImageXObject converted =
lineArtConversionService.convertImageToLineArt(
doc, originalImage, threshold, edgeLevel);
convertedImages.put(imageHash, converted);
stats.compressedImages++;
int convertedSize = (int) converted.getCOSObject().getLength();
stats.totalCompressedBytes += convertedSize * references.size();
double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize);
log.info(
"Image hash {}: Line art conversion {} → {} (reduced by {}%)",
imageHash,
GeneralUtils.formatBytes(originalSize),
GeneralUtils.formatBytes(convertedSize),
String.format("%.1f", reductionPercentage));
}
return convertedImages;
}
// Run Ghostscript compression
private void applyGhostscriptCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
@@ -66,10 +66,15 @@ public class ConfigController {
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
configData.put("baseUrl", appConfig.getBaseUrl());
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Add frontendUrl for mobile scanner QR codes
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
@@ -124,6 +129,9 @@ public class ConfigController {
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
configData.put(
"enableDesktopInstallSlide",
applicationProperties.getSystem().getEnableDesktopInstallSlide());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
@@ -227,4 +235,10 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/group-enabled")
public ResponseEntity<Boolean> isGroupEnabled(@RequestParam(name = "group") String group) {
boolean enabled = endpointConfiguration.isGroupEnabled(group);
return ResponseEntity.ok(enabled);
}
}
@@ -0,0 +1,219 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
/**
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
* that can be retrieved by desktop clients via a session-based system. No authentication required
* for peer-to-peer scanning workflow.
*/
@RestController
@RequestMapping("/api/v1/mobile-scanner")
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
private final MobileScannerService mobileScannerService;
public MobileScannerController(MobileScannerService mobileScannerService) {
this.mobileScannerService = mobileScannerService;
}
/**
* Upload files from mobile device
*
* @param sessionId Unique session identifier from QR code
* @param files Files to upload
* @return Upload status
*/
@PostMapping("/upload/{sessionId}")
@Operation(
summary = "Upload scanned files from mobile device",
description = "Mobile devices upload scanned images to a temporary session")
@ApiResponse(
responseCode = "200",
description = "Files uploaded successfully",
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
@ApiResponse(responseCode = "500", description = "Upload failed")
public ResponseEntity<Map<String, Object>> uploadFiles(
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
String sessionId,
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
List<MultipartFile> files) {
try {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
}
mobileScannerService.uploadFiles(sessionId, files);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionId);
response.put("filesUploaded", files.size());
response.put("message", "Files uploaded successfully");
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
log.error("Failed to upload files for session: {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to save files"));
}
}
/**
* Get list of uploaded files for a session
*
* @param sessionId Session identifier
* @return List of file metadata
*/
@GetMapping("/files/{sessionId}")
@Operation(
summary = "Get uploaded files for a session",
description = "Desktop clients poll this endpoint to check for new uploads")
@ApiResponse(
responseCode = "200",
description = "File list retrieved",
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
public ResponseEntity<Map<String, Object>> getSessionFiles(
@Parameter(description = "Session ID", required = true) @PathVariable
String sessionId) {
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("sessionId", sessionId);
response.put("files", files);
response.put("count", files.size());
return ResponseEntity.ok(response);
}
/**
* Download a specific file from a session
*
* @param sessionId Session identifier
* @param filename Filename to download
* @return File content
*/
@GetMapping("/download/{sessionId}/{filename}")
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "404", description = "File or session not found")
public ResponseEntity<Resource> downloadFile(
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
try {
Path filePath = mobileScannerService.getFile(sessionId, filename);
// Read file into memory first, so we can delete it before sending
byte[] fileBytes = Files.readAllBytes(filePath);
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
// Delete file immediately after reading into memory (server-side cleanup)
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
// Serve from memory
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.warn("File not found: session={}, file={}", sessionId, filename);
return ResponseEntity.notFound().build();
}
}
/**
* Delete a session and all its files
*
* @param sessionId Session to delete
* @return Deletion status
*/
@DeleteMapping("/session/{sessionId}")
@Operation(
summary = "Delete a session",
description = "Manually delete a session and all its uploaded files")
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
public ResponseEntity<Map<String, Object>> deleteSession(
@Parameter(description = "Session ID to delete", required = true) @PathVariable
String sessionId) {
mobileScannerService.deleteSession(sessionId);
return ResponseEntity.ok(
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
}
// Response schemas for OpenAPI documentation
private static class UploadResponse {
public boolean success;
public String sessionId;
public int filesUploaded;
public String message;
}
private static class FileListResponse {
public String sessionId;
public List<FileMetadata> files;
public int count;
}
}
@@ -191,6 +191,12 @@ public class CertSignController {
switch (certType) {
case "PEM":
privateKeyFile =
validateFilePresent(
privateKeyFile, "PEM private key", "private key file is required");
certFile =
validateFilePresent(
certFile, "PEM certificate", "certificate file is required");
ks = KeyStore.getInstance("JKS");
ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
@@ -200,10 +206,16 @@ public class CertSignController {
break;
case "PKCS12":
case "PFX":
p12File =
validateFilePresent(
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray());
break;
case "JKS":
jksfile =
validateFilePresent(
jksfile, "JKS keystore", "JKS keystore file is required");
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray());
break;
@@ -251,6 +263,17 @@ public class CertSignController {
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf"));
}
private MultipartFile validateFilePresent(
MultipartFile file, String argumentName, String errorDescription) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
argumentName + " - " + errorDescription);
}
return file;
}
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser =
@@ -74,13 +74,13 @@ public class ReactRoutingController {
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -0,0 +1,8 @@
package stirling.software.SPDF.exception;
public class CacheUnavailableException extends RuntimeException {
public CacheUnavailableException(String message) {
super(message);
}
}
@@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean grayscale = false;
@Schema(
description =
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean lineArt = false;
@Schema(
description = "Threshold to use for line art conversion (0-100).",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "55")
private Double lineArtThreshold = 55d;
@Schema(
description =
"Edge detection strength to use for line art conversion (1-3). This maps to"
+ " ImageMagick's -edge radius.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "1",
allowableValues = {"1", "2", "3"})
private Integer lineArtEdgeLevel = 1;
}
@@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.DateConverter;
import org.apache.pdfbox.util.Matrix;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -144,15 +143,23 @@ public class PdfJsonConversionService {
private final PdfJsonFontService fontService;
private final Type3FontConversionService type3FontConversionService;
private final Type3GlyphExtractor type3GlyphExtractor;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>();
@Value("${stirling.pdf.json.font-normalization.enabled:true}")
private boolean fontNormalizationEnabled;
private long cacheMaxBytes;
private int cacheMaxPercent;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
private final java.util.LinkedHashMap<String, CachedPdfDocument> lruCache =
new java.util.LinkedHashMap<>(16, 0.75f, true);
private final Object cacheLock = new Object();
private volatile long currentCacheBytes = 0L;
private volatile long cacheBudgetBytes = -1L;
private volatile boolean ghostscriptAvailable;
private static final float FLOAT_EPSILON = 0.0001f;
@@ -161,7 +168,23 @@ public class PdfJsonConversionService {
@PostConstruct
private void initializeToolAvailability() {
loadConfigurationFromProperties();
initializeGhostscriptAvailability();
initializeCacheBudget();
}
private void loadConfigurationFromProperties() {
stirling.software.common.model.ApplicationProperties.PdfEditor cfg =
applicationProperties.getPdfEditor();
if (cfg != null) {
fontNormalizationEnabled = cfg.getFontNormalization().isEnabled();
cacheMaxBytes = cfg.getCache().getMaxBytes();
cacheMaxPercent = cfg.getCache().getMaxPercent();
} else {
fontNormalizationEnabled = false;
cacheMaxBytes = -1;
cacheMaxPercent = 20;
}
}
private void initializeGhostscriptAvailability() {
@@ -202,6 +225,25 @@ public class PdfJsonConversionService {
}
}
private void initializeCacheBudget() {
long effective = -1L;
if (cacheMaxBytes > 0) {
effective = cacheMaxBytes;
} else if (cacheMaxPercent > 0) {
long maxMem = Runtime.getRuntime().maxMemory();
effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100);
}
cacheBudgetBytes = effective;
if (cacheBudgetBytes > 0) {
log.info(
"PDF JSON cache budget configured: {} bytes (source: {})",
cacheBudgetBytes,
cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
} else {
log.info("PDF JSON cache budget: unlimited");
}
}
public byte[] convertPdfToJson(MultipartFile file) throws IOException {
return convertPdfToJson(file, null, false);
}
@@ -236,7 +278,10 @@ public class PdfJsonConversionService {
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else {
jobId = contextJobId;
log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId);
log.info(
"Starting PDF to JSON conversion, jobId from context: {} (lightweight={})",
jobId,
lightweight);
}
Consumer<PdfJsonConversionProgress> progress =
@@ -318,9 +363,9 @@ public class PdfJsonConversionService {
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
int totalPages = document.getNumberOfPages();
// Only use lazy images for real async jobs where client can access the cache
// Synchronous calls with synthetic jobId should do full extraction
boolean useLazyImages = totalPages > 5 && isRealJobId;
// Always enable lazy mode for real async jobs so cache is available regardless of
// page count. Synchronous calls with synthetic jobId still do full extraction.
boolean useLazyImages = isRealJobId;
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
log.debug(
@@ -403,6 +448,11 @@ public class PdfJsonConversionService {
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
log.info(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
isRealJobId);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -435,16 +485,23 @@ public class PdfJsonConversionService {
cachedPdfBytes = Files.readAllBytes(workingPath);
}
CachedPdfDocument cached =
new CachedPdfDocument(
cachedPdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached);
log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}",
cachedPdfBytes.length,
buildCachedDocument(
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached);
log.info(
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
cached.getPdfSize(),
totalPages,
fonts.size(),
jobId);
jobId,
cached.isDiskBacked());
scheduleDocumentCleanup(jobId);
} else {
log.warn(
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
useLazyImages,
isRealJobId,
jobId);
}
if (lightweight) {
@@ -2973,6 +3030,139 @@ public class PdfJsonConversionService {
}
}
// Cache helpers
private CachedPdfDocument buildCachedDocument(
String jobId,
byte[] pdfBytes,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources)
throws IOException {
if (pdfBytes == null) {
throw new IllegalArgumentException("pdfBytes must not be null");
}
long budget = cacheBudgetBytes;
// If single document is larger than budget, spill straight to disk
if (budget > 0 && pdfBytes.length > budget) {
TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache");
Files.write(tempFile.getPath(), pdfBytes);
log.debug(
"Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}",
pdfBytes.length,
budget,
jobId);
return new CachedPdfDocument(
null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources);
}
return new CachedPdfDocument(
pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources);
}
private void putCachedDocument(String jobId, CachedPdfDocument cached) {
synchronized (cacheLock) {
CachedPdfDocument existing = documentCache.put(jobId, cached);
if (existing != null) {
lruCache.remove(jobId);
currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize());
existing.close();
}
lruCache.put(jobId, cached);
currentCacheBytes += cached.getInMemorySize();
enforceCacheBudget();
}
}
private CachedPdfDocument getCachedDocument(String jobId) {
synchronized (cacheLock) {
CachedPdfDocument cached = documentCache.get(jobId);
if (cached != null) {
lruCache.remove(jobId);
lruCache.put(jobId, cached);
}
return cached;
}
}
private void enforceCacheBudget() {
if (cacheBudgetBytes <= 0) {
return;
}
// Must be called under cacheLock
java.util.Iterator<java.util.Map.Entry<String, CachedPdfDocument>> it =
lruCache.entrySet().iterator();
while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) {
java.util.Map.Entry<String, CachedPdfDocument> entry = it.next();
it.remove();
CachedPdfDocument removed = entry.getValue();
documentCache.remove(entry.getKey(), removed);
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
removed.close();
log.warn(
"Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)",
entry.getKey(),
cacheBudgetBytes,
currentCacheBytes);
}
if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) {
// Spill the most recently used large entry to disk
String key =
lruCache.entrySet().stream()
.reduce((first, second) -> second)
.map(java.util.Map.Entry::getKey)
.orElse(null);
if (key != null) {
CachedPdfDocument doc = lruCache.get(key);
if (doc != null && doc.getInMemorySize() > 0) {
try {
CachedPdfDocument diskDoc =
buildCachedDocument(
key,
doc.getPdfBytes(),
doc.getMetadata(),
doc.getFonts(),
doc.getPageFontResources());
lruCache.put(key, diskDoc);
documentCache.put(key, diskDoc);
currentCacheBytes =
Math.max(0L, currentCacheBytes - doc.getInMemorySize())
+ diskDoc.getInMemorySize();
doc.close();
log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key);
} catch (IOException ex) {
log.warn(
"Failed to spill cached PDF for jobId {} to disk: {}",
key,
ex.getMessage());
}
}
}
}
}
private void removeCachedDocument(String jobId) {
log.warn(
"removeCachedDocument called for jobId: {} [CALLER: {}]",
jobId,
Thread.currentThread().getStackTrace()[2].toString());
CachedPdfDocument removed = null;
synchronized (cacheLock) {
removed = documentCache.remove(jobId);
if (removed != null) {
lruCache.remove(jobId);
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
log.warn(
"Removed cached document for jobId: {} (size={} bytes)",
jobId,
removed.getInMemorySize());
} else {
log.warn("Attempted to remove jobId: {} but it was not in cache", jobId);
}
}
if (removed != null) {
removed.close();
}
}
private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException {
if (element.getCharacterSpacing() != null) {
@@ -5311,6 +5501,8 @@ public class PdfJsonConversionService {
*/
private static class CachedPdfDocument {
private final byte[] pdfBytes;
private final TempFile pdfTempFile;
private final long pdfSize;
private final PdfJsonDocumentMetadata metadata;
private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
public CachedPdfDocument(
byte[] pdfBytes,
TempFile pdfTempFile,
long pdfSize,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources) {
this.pdfBytes = pdfBytes;
this.pdfTempFile = pdfTempFile;
this.pdfSize = pdfSize;
this.metadata = metadata;
// Create defensive copies to prevent mutation of shared maps
this.fonts =
@@ -5336,8 +5532,14 @@ public class PdfJsonConversionService {
}
// Getters return defensive copies to prevent external mutation
public byte[] getPdfBytes() {
return pdfBytes;
public byte[] getPdfBytes() throws IOException {
if (pdfBytes != null) {
return pdfBytes;
}
if (pdfTempFile != null) {
return Files.readAllBytes(pdfTempFile.getPath());
}
throw new IOException("Cached PDF backing missing");
}
public PdfJsonDocumentMetadata getMetadata() {
@@ -5352,6 +5554,18 @@ public class PdfJsonConversionService {
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources);
}
public long getPdfSize() {
return pdfSize;
}
public long getInMemorySize() {
return pdfBytes != null ? pdfBytes.length : 0L;
}
public boolean isDiskBacked() {
return pdfBytes == null && pdfTempFile != null;
}
public long getTimestamp() {
return timestamp;
}
@@ -5363,7 +5577,19 @@ public class PdfJsonConversionService {
public CachedPdfDocument withUpdatedFonts(
byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) {
Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts;
return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources);
return new CachedPdfDocument(
nextBytes,
null,
nextBytes != null ? nextBytes.length : 0,
metadata,
fontsToUse,
pageFontResources);
}
public void close() {
if (pdfTempFile != null) {
pdfTempFile.close();
}
}
}
@@ -5444,14 +5670,15 @@ public class PdfJsonConversionService {
// Cache PDF bytes, metadata, and fonts for lazy page loading
if (jobId != null) {
CachedPdfDocument cached =
new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached);
buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached);
log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}",
pdfBytes.length,
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})",
cached.getPdfSize(),
totalPages,
fonts.size(),
jobId);
jobId,
cached.isDiskBacked());
// Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId);
@@ -5466,9 +5693,10 @@ public class PdfJsonConversionService {
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = documentCache.get(jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document found for jobId: " + jobId);
}
int pageIndex = pageNumber - 1;
@@ -5480,8 +5708,8 @@ public class PdfJsonConversionService {
}
log.debug(
"Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})",
cached.getPdfBytes().length,
"Loading PDF from {} to extract page {} (jobId: {})",
cached.isDiskBacked() ? "disk cache" : "memory cache",
pageNumber,
jobId);
@@ -5627,10 +5855,21 @@ public class PdfJsonConversionService {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
CachedPdfDocument cached = documentCache.get(jobId);
log.info("Looking up cache for jobId: {}", jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document available for jobId: " + jobId);
log.error(
"Cache not found for jobId: {}. Available cache keys: {}",
jobId,
documentCache.keySet());
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document available for jobId: " + jobId);
}
log.info(
"Found cached document for jobId: {} (size={}, diskBacked={})",
jobId,
cached.getPdfSize(),
cached.isDiskBacked());
if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}",
@@ -5709,7 +5948,14 @@ public class PdfJsonConversionService {
document.save(baos);
byte[] updatedBytes = baos.toByteArray();
documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts));
CachedPdfDocument updated =
buildCachedDocument(
jobId,
updatedBytes,
cached.getMetadata(),
mergedFonts,
cached.getPageFontResources());
putCachedDocument(jobId, updated);
// Clear Type3 cache entries for this incremental update
clearType3CacheEntriesForJob(updateJobId);
@@ -5724,11 +5970,13 @@ public class PdfJsonConversionService {
/** Clears a cached document. */
public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = documentCache.remove(jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
removeCachedDocument(jobId);
if (cached != null) {
log.debug(
"Removed cached PDF bytes ({} bytes) for jobId: {}",
cached.getPdfBytes().length,
"Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}",
cached.getPdfSize(),
cached.isDiskBacked(),
jobId);
}
@@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService {
"ttf")));
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String legacyFallbackFontLocation;
private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
@jakarta.annotation.PostConstruct
private void loadConfig() {
String configured = null;
if (applicationProperties.getPdfEditor() != null) {
configured = applicationProperties.getPdfEditor().getFallbackFont();
}
if (configured != null && !configured.isBlank()) {
fallbackFontLocation = configured;
} else {
fallbackFontLocation = legacyFallbackFontLocation;
}
log.info("Using fallback font location: {}", fallbackFontLocation);
}
public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
@@ -5,7 +5,6 @@ import java.nio.file.Files;
import java.util.Base64;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager;
public class PdfJsonFontService {
private final TempFileManager tempFileManager;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Getter
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter private boolean cffConversionEnabled;
@Getter
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Getter private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand;
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript;
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable;
@@ -48,6 +41,7 @@ public class PdfJsonFontService {
@PostConstruct
private void initialiseCffConverterAvailability() {
loadConfiguration();
if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false;
@@ -77,6 +71,22 @@ public class PdfJsonFontService {
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
}
private void loadConfiguration() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getCffConverter() != null) {
var cfg = applicationProperties.getPdfEditor().getCffConverter();
this.cffConversionEnabled = cfg.isEnabled();
this.cffConverterMethod = cfg.getMethod();
this.pythonCommand = cfg.getPythonCommand();
this.pythonScript = cfg.getPythonScript();
this.fontforgeCommand = cfg.getFontforgeCommand();
} else {
// Use defaults when config is not available
this.cffConversionEnabled = false;
log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled");
}
}
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn(
@@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled;
@Override
@@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
return enabled && fontLibrary != null && fontLibrary.isLoaded();
}
@jakarta.annotation.PostConstruct
private void loadConfiguration() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getType3() != null
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
var cfg = applicationProperties.getPdfEditor().getType3().getLibrary();
this.enabled = cfg.isEnabled();
} else {
this.enabled = false;
log.warn("PdfEditor Type3 library configuration not available, disabled");
}
}
@Override
public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
@@ -14,7 +14,6 @@ import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
@@ -34,8 +33,8 @@ public class Type3FontLibrary {
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
private String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
@@ -44,6 +43,17 @@ public class Type3FontLibrary {
@jakarta.annotation.PostConstruct
void initialise() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getType3() != null
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
this.indexLocation =
applicationProperties.getPdfEditor().getType3().getLibrary().getIndex();
} else {
log.warn(
"[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled");
entries = List.of();
return;
}
Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
@@ -58,6 +58,8 @@ security:
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
@@ -126,13 +128,15 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -178,23 +182,6 @@ system:
databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
stirling:
pdf:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
json:
font-normalization:
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
cff-converter:
enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
python-command: /opt/venv/bin/python3 # Python interpreter path
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
type3:
library:
enabled: true # Match common Type3 fonts against the built-in library of converted programs
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
@@ -223,6 +210,7 @@ processExecutor:
weasyPrintSessionLimit: 16
installAppSessionLimit: 1
calibreSessionLimit: 1
imageMagickSessionLimit: 4
ghostscriptSessionLimit: 8
ocrMyPdfSessionLimit: 2
timeoutMinutes: # Process executor timeout in minutes
@@ -232,7 +220,26 @@ processExecutor:
weasyPrinttimeoutMinutes: 30
installApptimeoutMinutes: 60
calibretimeoutMinutes: 30
imageMagickTimeoutMinutes: 30
tesseractTimeoutMinutes: 30
qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 30
pdfEditor:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
cache:
max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap
max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0
font-normalization:
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
cff-converter:
enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
python-command: /opt/venv/bin/python3 # Python interpreter path
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
type3:
library:
enabled: true # Match common Type3 fonts against the built-in library of converted programs
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
@@ -1,53 +1,99 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: var(--md-sys-color-inverse-on-surface);
--cc-primary-color: var(--md-sys-color-on-surface);
--cc-secondary-color: var(--md-sys-color-on-surface);
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: var(--md-sys-color-secondary);
--cc-btn-primary-color: var(--cc-bg);
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
--cc-btn-secondary-hover-color: var(--cc-bg);
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: var(--md-sys-color-outline);
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
--cc-toggle-off-bg: var(--md-sys-color-outline);
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-section-category-border: var(--md-sys-color-outline);
--cc-section-category-border: #555555;
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
}
.cm__body{
max-width: 90% !important;
@@ -78,7 +124,83 @@
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
@@ -1,20 +1,25 @@
{
"name": "Stirling-PDF",
"short_name": "Stirling-PDF",
"short_name": "Stirling PDF",
"name": "Stirling PDF",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
"src": "modern-logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"src": "modern-logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
"theme_color": "#000000",
"background_color": "#ffffff"
}
@@ -1,9 +1,10 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
@@ -107,7 +108,8 @@ class CertSignControllerTest {
derCertBytes = baos.toByteArray();
}
when(pdfDocumentFactory.load(any(MultipartFile.class)))
lenient()
.when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
invocation -> {
MultipartFile file = invocation.getArgument(0);
@@ -167,6 +169,31 @@ class CertSignControllerTest {
assertTrue(response.getBody().length > 0);
}
@Test
void testSignPdfWithMissingPkcs12FileThrowsError() {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
request.setFileInput(pdfFile);
request.setCertType("PFX");
request.setPassword("password");
request.setShowSignature(false);
request.setReason("test");
request.setLocation("test");
request.setName("tester");
request.setPageNumber(1);
request.setShowLogo(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@Test
void testSignPdfWithJks() throws Exception {
MockMultipartFile pdfFile =
@@ -94,6 +94,22 @@ public class ProprietaryUIDataController {
this.auditRepository = auditRepository;
}
/**
* Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set,
* otherwise defaults to http://localhost:8080
*/
private String getBackendBaseUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
// If backendUrl is configured, use it
if (backendUrl != null && !backendUrl.trim().isEmpty()) {
return backendUrl.trim();
}
// For development, default to localhost:8080 (backend port)
return "http://localhost:8080";
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@@ -185,14 +201,17 @@ public class ProprietaryUIDataController {
}
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
// For SAML, we need to use the backend URL directly, not a relative path
// This ensures Spring Security generates the correct ACS URL
String backendUrl = getBackendBaseUrl();
String fullSamlPath = backendUrl + saml2AuthenticationPath;
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
}
}
@@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
@@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService;
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@@ -120,9 +120,7 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
@@ -334,7 +334,8 @@ public class SecurityConfiguration {
securityProperties.getSaml2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
@@ -343,7 +344,8 @@ public class SecurityConfiguration {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
});
})
.saml2Metadata(metadata -> {});
}
} else {
log.debug("Login is not enabled.");
@@ -244,10 +244,13 @@ public class AuthController {
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
userMap.put(
"authenticationType",
user.getAuthenticationType()); // Expose authentication type for SSO detection
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata);
return userMap;
@@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isSamlEligible(null)) {
@@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
@@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
response.sendRedirect(
contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
return;
}
try {
if (!userExists || saml2Properties.getBlockRegistration()) {
log.debug("Registration blocked for new user: {}", username);
response.sendRedirect(
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
// Block new users only if: blockRegistration is true OR autoCreateUser is false
if (!userExists
&& (saml2Properties.getBlockRegistration()
|| !saml2Properties.getAutoCreateUser())) {
log.debug(
"Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
username,
saml2Properties.getBlockRegistration(),
saml2Properties.getAutoCreateUser());
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?maxUsersReached=true");
return;
}
@@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
String origin = resolveOrigin(request);
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
/**
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
* then falls back to detecting from request headers.
*/
private String resolveOrigin(HttpServletRequest request) {
// First check if frontendUrl is configured
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
return configuredFrontendUrl.trim();
}
// Fall back to auto-detection from request headers
return resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
@@ -41,25 +41,92 @@ public class Saml2Configuration {
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert());
log.info(
"Initializing SAML2 configuration with registration ID: {}",
samlConf.getRegistrationId());
// Load IdP certificate
X509Certificate idpCert;
try {
Resource idpCertResource = samlConf.getIdpCert();
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
log.error(
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
idpCert = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
idpCert.getSubjectX500Principal().getName());
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Saml2X509Credential signingCredential =
new Saml2X509Credential(
CertificateUtils.readPrivateKey(privateKeyResource),
CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING);
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
if (!privateKeyResource.exists()) {
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
throw new IllegalStateException(
"SAML2 SP private key file does not exist: "
+ privateKeyResource.getDescription());
}
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
if (!certificateResource.exists()) {
log.error(
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
throw new IllegalStateException(
"SAML2 SP certificate file does not exist: "
+ certificateResource.getDescription());
}
Saml2X509Credential signingCredential;
try {
signingCredential =
new Saml2X509Credential(
CertificateUtils.readPrivateKey(privateKeyResource),
CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING);
log.info("Successfully loaded SP credentials");
} catch (Exception e) {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl == null || backendUrl.isBlank()) {
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
log.warn(
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
} else {
log.info("Using configured backend URL for SAML: {}", backendUrl);
}
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(samlConf.getIdpIssuer())
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation("http://localhost:8080/login")
.singleLogoutServiceResponseLocation(sloResponseLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
@@ -75,9 +142,14 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
"http://localhost:8080/login")
sloResponseLocation)
.wantAuthnRequestsSigned(true))
.build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
@Service
public class ImageMagickLineArtConversionService implements LineArtConversionService {
@Override
public PDImageXObject convertImageToLineArt(
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
throws IOException {
Path inputImage = Files.createTempFile("lineart_image_input_", ".png");
Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff");
try {
ImageIO.write(originalImage.getImage(), "png", inputImage.toFile());
List<String> command = new ArrayList<>();
command.add("magick");
command.add(inputImage.toString());
command.add("-colorspace");
command.add("Gray");
// Edge-aware line art conversion using ImageMagick's built-in operators.
// -edge/-negate/-normalize are standard convert options (IM v6+/v7) that
// accentuate outlines before thresholding to a bilevel image.
command.add("-edge");
command.add(String.valueOf(edgeLevel));
command.add("-negate");
command.add("-normalize");
command.add("-type");
command.add("Bilevel");
command.add("-threshold");
command.add(String.format(Locale.ROOT, "%.1f%%", threshold));
command.add("-compress");
command.add("Group4");
command.add(outputImage.toString());
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0) {
log.warn(
"ImageMagick line art conversion failed with return code: {}",
result.getRc());
throw new IOException("ImageMagick line art conversion failed");
}
byte[] convertedBytes = Files.readAllBytes(outputImage);
return PDImageXObject.createFromByteArray(
doc, convertedBytes, originalImage.getCOSObject().toString());
} catch (Exception e) {
log.warn("ImageMagick line art conversion failed", e);
throw new IOException("ImageMagick line art conversion failed", e);
} finally {
Files.deleteIfExists(inputImage);
Files.deleteIfExists(outputImage);
}
}
}
@@ -177,6 +177,13 @@ public class UserLicenseSettingsService {
*/
@Transactional
public void grandfatherExistingOAuthUsers() {
// Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install
Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer();
if (Boolean.TRUE.equals(isNewServer)) {
log.info("Fresh V2 installation detected - skipping OAuth user grandfathering");
return;
}
UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration
@@ -348,30 +355,22 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
// Check license first - if paying, they're eligible (no need to check grandfathering)
boolean hasPaid = hasPaidLicense();
if (hasPaid) {
log.debug("User {} eligible for OAuth via paid license", username);
return true;
}
// todo: remove
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} eligible for OAuth via grandfathering (no paid license)", username);
return true;
}
// Users can use OAuth with SERVER or ENTERPRISE license
boolean hasPaid = hasPaidLicense();
log.info(
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
// Not grandfathered and no license
log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username);
return false;
}
/**
@@ -391,29 +390,26 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
// Check license first - if paying, they're eligible (no need to check grandfathering)
boolean hasEnterprise = hasEnterpriseLicense();
if (hasEnterprise) {
log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
return true;
}
if (user != null) {
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
"User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
username);
return true;
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
// Not grandfathered and no license
log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
hasEnterprise,
username,
hasEnterprise);
return hasEnterprise;
"User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered",
username);
return false;
}
/**
@@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest {
@Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium;
@Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
@@ -49,6 +50,9 @@ class UserLicenseSettingsServiceTest {
mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium);
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getIsNewServer())
.thenReturn(false); // Default: not a new server
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.1.3'
version = '2.1.4'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+1
View File
@@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
gcompat \
libc6-compat \
libreoffice \
imagemagick \
# pdftohtml
poppler-utils \
# OCR MY PDF
+1
View File
@@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+1
View File
@@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+1
View File
@@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+1
View File
@@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+3
View File
@@ -19,5 +19,8 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="src/index.tsx"></script>
<!-- jscanify and OpenCV for mobile scanner - loaded after React for non-blocking page load -->
<script src="/vendor/jscanify/opencv.js" async></script>
<script src="/vendor/jscanify/jscanify.js" async></script>
</body>
</html>
+1151 -2563
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -54,7 +54,9 @@
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
@@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung"
applySignatures = "Text anwenden"
[addText.text]
name = "Textinhalt"
placeholder = "Geben Sie den hinzuzufügenden Text ein"
name = "Text"
placeholder = "Text eingeben"
fontLabel = "Schriftart"
fontSizeLabel = "Schriftgröße"
fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)"
+460 -127
View File
@@ -231,6 +231,26 @@ failed = "Add page numbers operation failed"
[addPageNumbers.results]
title = "Page Number Results"
[addPageNumbers.help]
title = "Page Numbers & Bates Numbering Help"
variables = "Variable Substitution"
variablesDesc = "Use these variables in the Custom Text Format field to create dynamic page numbering:"
variableN = "{n} - Current page number"
variableTotal = "{total} - Total number of pages"
variableFilename = "{filename} - Document filename (without extension)"
examples = "Common Examples"
exampleSimple = "Simple numbering: {n} → 1, 2, 3, 4..."
examplePageOf = "Page X of Y: Page {n} of {total} → Page 1 of 10, Page 2 of 10..."
exampleBates = "Legal Bates numbering: ABC-{n} → ABC-001, ABC-002, ABC-003..."
exampleDocument = "Document reference: {filename}-{n} → mydoc-001, mydoc-002..."
exampleCustom = "Custom format: Doc {filename} | Page {n}/{total}"
positioning = "Positioning"
positioningDesc = "Use the 1-9 grid system to quickly position page numbers, or use the margin size to adjust distance from edges."
tips = "Tips"
tip1 = "Starting Number: Change the starting number to begin counting from any value (useful for multi-part documents)"
tip2 = "Page Selection: Number only specific pages by entering ranges like 1,3,5-8"
tip3 = "Formatting: Choose font type, size, and colour to match your document style"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
@@ -312,10 +332,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f
ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro"
[analytics]
title = "Do you want make Stirling PDF better?"
paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents."
title = "Do you want to help make Stirling PDF better?"
paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
learnMore = "Learn more"
learnMore = "Learn more about our analytics"
enable = "Enable analytics"
disable = "Disable analytics"
settings = "You can change the settings for analytics in the config/settings.yml file"
@@ -340,6 +360,10 @@ advance = "Advanced"
edit = "View & Edit"
popular = "Popular"
[footer]
discord = "Discord"
issues = "GitHub"
[settings.preferences]
title = "Preferences"
@@ -435,6 +459,25 @@ latestVersion = "Latest Version"
checkForUpdates = "Check for Updates"
viewDetails = "View Details"
[settings.security]
title = "Security"
description = "Update your password to keep your account secure."
[settings.security.password]
subtitle = "Change your password. You will be logged out after updating."
required = "All fields are required."
mismatch = "New passwords do not match."
error = "Unable to update password. Please verify your current password and try again."
success = "Password updated successfully. Please sign in again."
ssoDisabled = "Password changes are managed by your identity provider."
current = "Current password"
currentPlaceholder = "Enter your current password"
new = "New password"
newPlaceholder = "Enter a new password"
confirm = "Confirm new password"
confirmPlaceholder = "Re-enter your new password"
update = "Update password"
[settings.hotkeys]
title = "Keyboard Shortcuts"
description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel."
@@ -488,11 +531,16 @@ low = "Low"
title = "Change Credentials"
header = "Update Your Account Details"
changePassword = "You are using default login credentials. Please enter a new password"
ssoManaged = "Your account is managed by your identity provider."
newUsername = "New Username"
oldPassword = "Current Password"
newPassword = "New Password"
confirmNewPassword = "Confirm New Password"
submit = "Submit Changes"
credsUpdated = "Account updated"
description = "Changes saved. Please log in again."
error = "Unable to update username. Please verify your password and try again."
changeUsername = "Update your username. You will be logged out after updating."
[account]
title = "Account Settings"
@@ -629,24 +677,24 @@ title = "PDF Multi Tool"
desc = "Merge, Rotate, Rearrange, Split, and Remove pages"
[home.merge]
tags = "combine,join,unite"
title = "Merge"
desc = "Easily merge multiple PDFs into one."
tags = "combine,join,unite,merge,collate,append,concatenate,put together,bind,join files,multiple PDFs into one,PDF merge"
title = "Merge / Combine PDFs"
desc = "Easily merge multiple PDFs into one document"
[home.split]
tags = "divide,separate,break"
tags = "divide,separate,break,burst,extract pages,split by size,split by chapters,partition,break into files,disassemble,PDF split"
title = "Split"
desc = "Split PDFs into multiple documents"
desc = "Split PDFs by page numbers, file size, page count, chapters, or sections. Multiple splitting methods available."
[home.rotate]
tags = "turn,flip,orient"
tags = "turn,flip,orient,rotate,spin,turn pages,flip orientation,rotate PDF,clockwise,counterclockwise"
title = "Rotate"
desc = "Easily rotate your PDFs."
desc = "Easily rotate your PDFs clockwise or counterclockwise"
[home.convert]
tags = "transform,change"
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to JPG,Word to PDF,export,save as,format conversion,DOCX,XLSX,PPT,image to PDF"
title = "Convert"
desc = "Convert files between different formats"
desc = "Convert between PDF and other formats: Word (DOCX), Excel (XLSX), PowerPoint (PPTX), Images (JPG/PNG), HTML, and more"
[home.pdfOrganiser]
tags = "organize,rearrange,reorder"
@@ -654,159 +702,159 @@ title = "Organise"
desc = "Remove/Rearrange pages in any order"
[home.addImage]
tags = "insert,embed,place"
title = "Add image"
desc = "Adds a image onto a set location on the PDF"
tags = "insert,embed,place,add image,insert image,add logo,add photo,place image,insert graphic,embed image"
title = "Insert Image into PDF"
desc = "Add images to PDF at specific locations. Insert logos, signatures, diagrams, or photos."
[home.addAttachments]
tags = "embed,attach,include"
title = "Add Attachments"
desc = "Add or remove embedded files (attachments) to/from a PDF"
tags = "embed,attach,include,add files,embed files,attach documents,extract attachments,embedded files,PDF portfolio"
title = "Add/Remove Embedded Files"
desc = "Attach files to PDF (embed documents, spreadsheets, images) or extract existing attachments"
[home.watermark]
tags = "stamp,mark,overlay"
tags = "stamp,mark,overlay,watermark,draft,confidential,copyright,brand,security watermark,tiled watermark,repeating pattern"
title = "Add Watermark"
desc = "Add a custom watermark to your PDF document."
desc = "Add text or image watermarks. Create tiled patterns or single placement. Flatten for security."
[home.removePassword]
tags = "unlock"
title = "Remove Password"
desc = "Remove password protection from your PDF document."
tags = "unlock,decrypt,remove protection,remove security,unlock PDF,password removal,remove password"
title = "Unlock PDF / Remove Password"
desc = "Remove password protection from your PDF document"
[home.compress]
tags = "shrink,reduce,optimize"
tags = "shrink,reduce,optimize,reduce file size,make smaller,downsize,compress for email,optimize for web,downsample,reduce quality"
title = "Compress"
desc = "Compress PDFs to reduce their file size."
desc = "Reduce PDF file size by compressing images and optimizing content. Choose quality level or target file size."
[home.unlockPDFForms]
tags = "unlock,enable,edit"
title = "Unlock PDF Forms"
desc = "Remove read-only property of form fields in a PDF document."
tags = "unlock,enable,edit,unlock forms,make editable,remove read-only,enable form fields,editable forms"
title = "Unlock Form Fields"
desc = "Remove read-only restrictions from PDF form fields. Enable editing of locked forms."
[home.changeMetadata]
tags = "edit,modify,update"
title = "Change Metadata"
desc = "Change/Remove/Add metadata from a PDF document"
tags = "edit,modify,update,metadata,document properties,file info,XMP,Dublin Core,author,title,subject,keywords"
title = "Edit Metadata / Properties"
desc = "Change title, author, subject, keywords, and other document properties (XMP metadata)"
[home.ocr]
tags = "extract,scan"
title = "OCR / Cleanup scans"
desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text."
tags = "extract,scan,OCR,make searchable,text recognition,searchable PDF,extract text,scan to text,recognize text,optical character recognition"
title = "OCR - Make PDF Searchable"
desc = "Extract text from scanned images using Optical Character Recognition. Makes PDFs searchable and editable."
[home.extractImages]
tags = "pull,save,export"
tags = "pull,save,export,extract images,get images,save images,export graphics,image extraction,pictures from PDF,save pictures"
title = "Extract Images"
desc = "Extracts all images from a PDF and saves them to zip"
desc = "Extract all images from PDF and save them as separate files in ZIP archive"
[home.scannerImageSplit]
tags = "detect,split,photos"
title = "Detect & Split Scanned Photos"
desc = "Detect and split scanned photos into separate pages"
tags = "detect,split,photos,extract photos,scan separation,photo detection,scanner image split,separate scanned photos,photo extraction"
title = "Extract Photos from Scan"
desc = "Automatically detect and separate individual photos from scanned pages. Advanced OpenCV-based detection."
[home.sign]
tags = "signature,autograph"
title = "Sign"
desc = "Adds signature to PDF by drawing, text or image"
tags = "signature,autograph,e-sign,electronic signature,fill and sign,sign PDF online,add signature,e-signature"
title = "E-Sign / Fill & Sign"
desc = "Add electronic signature by drawing, typing, or uploading image. For legal digital signatures, use Certificate Sign tool."
[home.flatten]
tags = "simplify,remove,interactive"
title = "Flatten"
desc = "Remove all interactive elements and forms from a PDF"
tags = "simplify,remove,interactive,flatten,rasterize,make non-editable,burn in,lock form,static PDF,remove form fields"
title = "Flatten PDF"
desc = "Convert interactive elements to static content. Removes form editability, burns in annotations, and locks content."
[home.certSign]
tags = "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto"
title = "Sign with Certificate"
desc = "Signs a PDF with a Certificate/Key (PEM/P12)"
tags = "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto,digital signature,PKI signature,cryptographic signature,certificate-based"
title = "Digital Signature (Certificate)"
desc = "Add cryptographic digital signature using certificate (PEM/P12/PKCS12). More secure than basic e-signature."
[home.repair]
tags = "fix,restore"
tags = "fix,restore,repair,corrupted PDF,damaged PDF,broken PDF,recover,fix errors,repair corrupted"
title = "Repair"
desc = "Tries to repair a corrupt/broken PDF"
desc = "Attempt to repair corrupted or damaged PDF files. Recovers readable content when possible."
[home.removeBlanks]
tags = "delete,clean,empty"
title = "Remove Blank pages"
desc = "Detects and removes blank pages from a document"
tags = "delete,clean,empty,remove blank pages,remove empty pages,delete blank,clean empty,auto-remove blanks"
title = "Remove Blank/Empty Pages"
desc = "Automatically detect and remove blank pages from document"
[home.removeAnnotations]
tags = "delete,clean,strip"
title = "Remove Annotations"
desc = "Removes all comments/annotations from a PDF"
tags = "delete,clean,strip,remove annotations,remove comments,delete markup,strip comments,clean annotations"
title = "Remove Comments & Annotations"
desc = "Delete all comments, highlights, notes, and markup from PDF. Clean document for final version."
[home.compare]
tags = "difference"
tags = "difference,compare,redline,track changes,document comparison,find differences,version comparison,diff"
title = "Compare"
desc = "Compares and shows the differences between 2 PDF Documents"
desc = "Compare two PDF versions and highlight differences. Shows added, deleted, and modified content."
[home.removeCertSign]
tags = "remove,delete,unlock"
tags = "remove,delete,unlock,remove signature,remove certificate,unsign,remove digital signature"
title = "Remove Certificate Sign"
desc = "Remove certificate signature from PDF"
desc = "Remove certificate-based digital signatures from PDF. Note: May invalidate document authenticity."
[home.pageLayout]
tags = "layout,arrange,combine"
title = "Multi-Page Layout"
desc = "Merge multiple pages of a PDF document into a single page"
tags = "layout,arrange,combine,N-up,2-up,4-up,multiple pages per sheet,pages per sheet,grid layout,print multiple"
title = "Multi-Page Layout (N-Up)"
desc = "Combine multiple pages into single sheet. Create 2-up, 4-up, 9-up layouts for printing or viewing."
[home.bookletImposition]
tags = "booklet,print,binding"
title = "Booklet Imposition"
desc = "Create booklets with proper page ordering and multi-page layout for printing and binding"
tags = "booklet,print,binding,imposition,print booklet,duplex,gutter,saddle stitch,perfect binding,print layout"
title = "Create Booklet / Imposition"
desc = "Arrange pages for booklet printing with proper ordering. Supports duplex printing, gutter margins, and binding options."
[home.scalePages]
tags = "resize,adjust,scale"
title = "Adjust page size/scale"
desc = "Change the size/scale of a page and/or its contents."
tags = "resize,adjust,scale,page size,paper size,A4,Letter,Legal,scale content,change dimensions"
title = "Resize Pages / Change Page Size"
desc = "Change page dimensions or scale content. Convert between paper sizes (A4, Letter, Legal) or apply custom scaling."
[home.addPageNumbers]
tags = "number,pagination,count"
title = "Add Page Numbers"
desc = "Add Page numbers throughout a document in a set location"
tags = "number,pagination,count,bates,bates numbering,sequential,legal numbering,document numbering,header,footer,page numbers"
title = "Add Page Numbers / Bates"
desc = "Add page numbers or Bates-style sequential numbering with custom formatting (ABC-001, Page {n} of {total}). Use {n} for page number, {total} for total pages, {filename} for document name."
[home.autoRename]
tags = "auto-detect,header-based,organize,relabel"
title = "Auto Rename PDF File"
desc = "Auto renames a PDF file based on its detected header"
tags = "auto-detect,header-based,organize,relabel,auto rename,smart rename,detect title,organize files"
title = "Auto Rename by Header"
desc = "Automatically rename PDF files based on detected header or title text. Organize documents intelligently."
[home.adjustContrast]
tags = "contrast,brightness,saturation"
title = "Adjust Colours/Contrast"
desc = "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
tags = "contrast,brightness,saturation,adjust colors,enhance,improve readability,image enhancement,scan enhancement,color adjustment"
title = "Adjust Colors & Contrast"
desc = "Enhance PDF appearance by adjusting colors, contrast, saturation, and brightness. Improve readability of scanned documents."
[home.crop]
tags = "trim,cut,resize"
tags = "trim,cut,resize,crop,remove margins,crop margins,trim whitespace,adjust margins"
title = "Crop PDF"
desc = "Crop a PDF to reduce its size (maintains text!)"
desc = "Crop PDF pages to remove margins or unwanted areas. Maintains text and quality (not image-based)."
[home.autoSplitPDF]
tags = "auto,split,QR"
title = "Auto Split Pages"
desc = "Auto Split Scanned PDF with physical scanned page splitter QR Code"
tags = "auto,split,QR,QR code,divider,batch scanning,auto split,automatic splitting,duplex scan"
title = "Auto Split by QR Code"
desc = "Automatically split scanned PDFs using QR code divider pages. Supports duplex scanning mode."
[home.sanitize]
tags = "clean,purge,remove"
tags = "clean,purge,remove,sanitize,remove hidden data,privacy,GDPR,remove metadata,clean document,security,strip data"
title = "Sanitise"
desc = "Remove potentially harmful elements from PDF files"
desc = "Remove potentially harmful elements and hidden data from PDFs. Cleans metadata, scripts, and embedded content for privacy."
[home.getPdfInfo]
tags = "info,metadata,details"
tags = "info,metadata,details,document properties,file information,PDF info,view properties,document details"
title = "Get ALL Info on PDF"
desc = "Grabs any and all information possible on PDFs"
desc = "View comprehensive information about PDFs including metadata, properties, fonts, images, and structure"
[home.pdfToSinglePage]
tags = "combine,merge,single"
title = "PDF to Single Large Page"
desc = "Merges all PDF pages into one large single page"
tags = "combine,merge,single,single page,continuous page,vertical scroll,one page,poster,long page"
title = "Merge to Single Continuous Page"
desc = "Combine all pages into one large continuous page. Useful for vertical scrolling or poster creation."
[home.showJS]
tags = "javascript,code,script"
title = "Show Javascript"
desc = "Searches and displays any JS injected into a PDF"
tags = "javascript,code,script,view javascript,inspect code,security check,embedded scripts,PDF security"
title = "View Embedded JavaScript"
desc = "Display any JavaScript code embedded in PDF. Security tool to inspect potentially harmful scripts."
[home.redact]
tags = "censor,blackout,hide"
tags = "censor,blackout,hide,redact,remove sensitive information,permanent removal,privacy,GDPR,sanitize text,regex redaction,sensitive data"
title = "Redact"
desc = "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
desc = "Permanently remove sensitive content from PDFs. Supports text search, regex patterns, and manual selection. Cleans metadata."
[home.splitBySections]
tags = "split,sections,divide"
@@ -814,14 +862,14 @@ title = "Split PDF by Sections"
desc = "Divide each page of a PDF into smaller horizontal and vertical sections"
[home.addStamp]
tags = "stamp,mark,seal"
tags = "stamp,mark,seal,approval,reviewed,approved,confidential,certified,notarized,endorsement,business stamp"
title = "Add Stamp to PDF"
desc = "Add text or add image stamps at set locations"
desc = "Add approval stamps, custom text, or images with positioning control. Supports variables: {n}, {total}, {filename}. Multi-language support available."
[home.removeImage]
tags = "remove,delete,clean"
title = "Remove image"
desc = "Remove image from PDF to reduce file size"
tags = "remove,delete,clean,remove images,delete pictures,strip images,reduce size,remove graphics,delete photos"
title = "Remove Images from PDF"
desc = "Delete all images from PDF to reduce file size or remove sensitive visual content"
[home.splitByChapters]
tags = "split,chapters,structure"
@@ -829,9 +877,9 @@ title = "Split PDF by Chapters"
desc = "Split a PDF into multiple files based on its chapter structure."
[home.validateSignature]
tags = "validate,verify,certificate"
title = "Validate PDF Signature"
desc = "Verify digital signatures and certificates in PDF documents"
tags = "validate,verify,certificate,check signature,authenticity,signature verification,certificate check,PKI,digital signature validation"
title = "Verify Digital Signature"
desc = "Validate digital signatures and verify certificate authenticity. Check document integrity and signer identity."
[home.swagger]
tags = "API,documentation,test"
@@ -844,9 +892,9 @@ title = "Scanner Effect"
desc = "Create a PDF that looks like it was scanned"
[home.editTableOfContents]
tags = "bookmarks,contents,edit"
title = "Edit Table of Contents"
desc = "Add or edit bookmarks and table of contents in PDF documents"
tags = "bookmarks,contents,edit,table of contents,TOC,navigation,outline,document structure,edit bookmarks"
title = "Edit Bookmarks / TOC"
desc = "Add, edit, or remove PDF bookmarks (navigation outline). Create document structure for easy navigation."
[home.manageCertificates]
tags = "certificates,import,export"
@@ -854,24 +902,24 @@ title = "Manage Certificates"
desc = "Import, export, or delete digital certificate files used for signing PDFs."
[home.read]
tags = "view,open,display"
title = "Read"
desc = "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
tags = "view,open,display,read,annotate,comment,highlight,markup,PDF viewer,collaboration,review"
title = "View & Annotate PDF"
desc = "View PDFs with annotation tools. Highlight text, draw, add comments, and collaborate on documents."
[home.reorganizePages]
tags = "rearrange,reorder,organize"
title = "Reorganize Pages"
desc = "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
tags = "rearrange,reorder,organize,organize pages,resequence,resort,change page order,move pages,drag and drop,PDF organize"
title = "Organize Pages"
desc = "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control"
[home.extractPages]
tags = "pull,select,copy"
tags = "pull,select,copy,extract pages,save pages,copy pages,pull pages,select pages,PDF extract"
title = "Extract Pages"
desc = "Extract specific pages from a PDF document"
desc = "Extract specific pages from a PDF document and save them separately"
[home.removePages]
tags = "delete,extract,exclude"
tags = "delete,extract,exclude,remove pages,delete pages,take out pages,discard pages,PDF delete"
title = "Remove Pages"
desc = "Remove specific pages from a PDF document"
desc = "Remove specific pages from a PDF document permanently"
[home.autoSizeSplitPDF]
tags = "auto,split,size"
@@ -879,8 +927,9 @@ title = "Auto Split by Size/Count"
desc = "Automatically split PDFs by file size or page count"
[home.replaceColor]
title = "Replace & Invert Colour"
desc = "Replace or invert colours in PDF documents"
tags = "replace color,invert,color swap,dark mode,invert colors,change colors,color transformation,accessibility"
title = "Replace & Invert Colors"
desc = "Swap colors in PDF or invert all colors. Useful for dark mode, accessibility, or color correction."
[home.devApi]
tags = "API,development,documentation"
@@ -901,12 +950,14 @@ title = "Air-gapped Setup"
desc = "Link to air-gapped setup guide"
[home.addPassword]
title = "Add Password"
desc = "Add password protection and restrictions to PDF files"
tags = "password,protect,encrypt,secure,lock,add password,security,user password,owner password,permissions,password protect"
title = "Password Protect / Encrypt PDF"
desc = "Add password protection to secure your PDF. Set user password (open document) or owner password (permissions)."
[home.changePermissions]
title = "Change Permissions"
desc = "Change document restrictions and permissions"
tags = "permissions,restrictions,security,access control,disable printing,disable copying,owner password,restrict editing,PDF permissions"
title = "Set Permissions / Restrictions"
desc = "Control what users can do with your PDF: printing, copying text, editing, form filling, and more"
[home.automate]
tags = "workflow,sequence,automation"
@@ -914,22 +965,25 @@ title = "Automate"
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
[home.overlay-pdfs]
desc = "Overlay one PDF on top of another"
tags = "overlay,superimpose,combine,merge layers,background,letterhead,template,place over"
title = "Overlay PDFs"
desc = "Superimpose one PDF on top of another. Useful for adding letterheads, templates, or background designs."
[home.pdfTextEditor]
title = "PDF Text Editor"
desc = "Edit existing text and images inside PDFs"
tags = "edit,text,modify,edit PDF,change text,edit content,PDF editor,text editing,modify text"
title = "Edit PDF Text Directly"
desc = "Edit text content directly in PDF. Modify, add, or delete text like a word processor."
[home.addText]
tags = "text,annotation,label"
tags = "text,annotation,label,insert text,add label,text overlay,custom text,add note,text placement"
title = "Add Text"
desc = "Add custom text anywhere in your PDF"
desc = "Add custom text anywhere in your PDF with full positioning and formatting control"
[landing]
addFiles = "Add Files"
uploadFromComputer = "Upload from computer"
openFromComputer = "Open from computer"
mobileUpload = "Upload from Mobile"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
@@ -1534,6 +1588,16 @@ bullet4 = "Best for sensitive or copyrighted content"
1 = "Text"
2 = "Image"
[watermark.help]
title = "Advanced Watermark Features"
tiledPattern = "Creating Tiled/Repeating Patterns"
tiledDesc = "Use the Horizontal and Vertical Spacing controls to create a repeating watermark pattern across the entire page. Set both values to create a grid of watermarks (useful for 'CONFIDENTIAL' or 'DRAFT' labels)."
tiledExample = "Example: Set horizontal spacing to 200 and vertical spacing to 150 to create a diagonal pattern of watermarks."
security = "Security Flatten Option"
securityDesc = "Enable 'Flatten PDF pages to images' to convert the PDF to images, making the watermark impossible to remove. Note: This increases file size and makes text non-selectable."
multiLanguage = "Multi-Language Support"
multiLanguageDesc = "Choose the appropriate Font/Language option (Roman, Arabic, Japanese, Korean, Chinese, Thai) to ensure your watermark text displays correctly with proper fonts."
[permissions]
tags = "read,write,edit,print"
title = "Change Permissions"
@@ -2203,6 +2267,25 @@ tip4 = "Clean the scanner glass"
headsUp = "Heads-up"
headsUpDesc = "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
[scannerImageSplit.help]
title = "Advanced OpenCV Parameters"
overview = "This tool uses OpenCV (computer vision library) to automatically detect individual photos on scanned pages."
angleThreshold = "Angle Threshold (Default: 5)"
angleThresholdDesc = "Rotation angle in degrees needed before auto-straightening a photo. Lower values (1-3) straighten more aggressively, higher values (10-15) only straighten very tilted photos."
tolerance = "Tolerance (Default: 20)"
toleranceDesc = "How closely a colour must match the page background to count as background. Higher values (30-50) detect photos more easily but may include background noise. Lower values (10-15) are stricter."
minArea = "Minimum Area (Default: 8000)"
minAreaDesc = "Smallest photo size in pixels² to keep. Increase to 15,000-20,000 to filter out small fragments. Decrease to 3000-5000 to detect smaller photos."
minContourArea = "Minimum Contour Area (Default: 500)"
minContourAreaDesc = "Smallest edge/shape size when detecting photo boundaries. Increase to 1000-2000 to filter out dust and specks. Lower values detect finer edges."
borderSize = "Border Size (Default: 1)"
borderSizeDesc = "Extra padding in pixels around each extracted photo. Increase to 5-10 to avoid cutting edges. Set to 0 for no padding."
recommendedSettings = "Recommended Settings"
normalScans = "Normal photo scans: Use defaults (Angle: 5, Tolerance: 20, Min Area: 8000)"
highQuality = "High-quality photos on clean background: Tolerance 15, Min Area 10000, Border 3"
noisyScans = "Noisy/dirty scans: Tolerance 30, Min Contour Area 1500, Border 5"
smallPhotos = "Small photos (ID cards, stamps): Min Area 3000, Border 2"
[sign]
title = "Sign"
header = "Sign PDFs"
@@ -2376,6 +2459,31 @@ bullet2 = "Links still work when clicked"
bullet3 = "Comments and notes remain visible"
bullet4 = "Bookmarks still help you navigate"
[flatten.help]
title = "Flatten PDF Guide"
overview = "Flattening converts interactive PDF elements into static content. Makes forms non-editable and removes interactivity."
whatGetsFlattened = "What Gets Flattened"
fullFlatten = "Full Flatten (default): Text fields, checkboxes, radio buttons, dropdowns, buttons, annotations, and all interactive elements become static images/text."
formsOnly = "Forms Only: Only form fields become static. Links, bookmarks, comments, and annotations remain interactive."
whenToFlatten = "When to Flatten"
useCase1 = "Completed forms: After filling out a form, flatten to prevent further changes"
useCase2 = "Final documents: Create locked versions for record-keeping or distribution"
useCase3 = "Watermarked docs: After adding watermarks, flatten to prevent removal"
useCase4 = "Pre-printed forms: Convert fillable PDFs to printable forms with visible fields"
useCase5 = "Consistency: Ensure PDF looks identical across all viewers and devices"
whatStaysInteractive = "What Stays Interactive (Forms Only mode)"
interactive1 = "Hyperlinks and web links remain clickable"
interactive2 = "Bookmarks/table of contents for navigation"
interactive3 = "Comments and annotations remain visible and editable"
interactive4 = "Document outline and layers"
important = "Important Notes"
note1 = "Flattening is permanent - cannot be reversed. Keep original if you need to edit later."
note2 = "File size may increase slightly as form elements become images"
note3 = "Flattened forms cannot be un-flattened - the form data is permanently merged"
note4 = "Digital signatures may be invalidated by flattening"
alternatives = "Alternative to Flattening"
altPermissions = "If you only want to prevent editing, consider using 'Change Permissions' instead of flattening. This keeps the PDF interactive but locked."
[repair]
tags = "fix,restore,correction,recover"
title = "Repair"
@@ -2710,6 +2818,29 @@ bullet4 = "JKS Java .jks keystore for dev / CI-CD workflows"
title = "Key not listed?"
text = "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
[certSign.help]
title = "Digital Signature Quick Reference"
overview = "Digital signatures provide cryptographic proof of document authenticity and integrity. They show who signed and detect any modifications after signing."
manualVsAuto = "Signing Modes"
manualMode = "Manual: Use your own certificate (PEM/P12/PKCS12/JKS). Shows as 'Trusted' if certificate authority is recognized. Best for legal/client-facing documents."
autoMode = "Auto: Uses server-generated certificate. Always tamper-evident but shows as 'Unverified'. Fast, no setup. Best for internal workflows."
certificateFormats = "Certificate Format Guide"
pem = "PEM: Two files needed - certificate (.crt/.pem) + private key (.key). Common in Linux/web servers."
p12pkcs12 = "P12/PKCS12/PFX: Single file with certificate + key, password-protected. Windows-friendly format."
jks = "JKS: Java KeyStore format. Common in Java applications."
visibleVsInvisible = "Signature Visibility"
visible = "Visible: Shows signature block on PDF with name, date, reason, location. Choose page number for placement."
invisible = "Invisible: No visual change to PDF. Signature embedded for verification only. Best when document layout mustn't change."
security = "Security Notes"
securityTamper = "Any edit after signing invalidates the signature - PDF readers will show 'Document Modified'"
securityChain = "Certificate trust depends on Certificate Authority (CA) chain recognition by PDF readers"
securityPassword = "Password-protected certificates (.p12/.jks) require password to sign"
tips = "Tips"
tip1 = "Test signatures with PDF readers (Adobe, browsers) to verify they show as expected"
tip2 = "For legal documents, use Manual mode with a certificate from a recognized CA"
tip3 = "For internal tracking/audit trails, Auto mode is simpler and equally tamper-evident"
tip4 = "Invisible signatures don't alter page count or layout - useful for pre-printed forms"
[removeCertSign]
tags = "authenticate,PEM,P12,official,decrypt"
title = "Remove Certificate Signature"
@@ -2834,6 +2965,27 @@ bullet2 = "Borders: Shows cut lines for trimming"
bullet3 = "Gutter Margin: Adds space for binding/stapling"
bullet4 = "Short-edge Flip: Only for automatic duplex printers"
[bookletImposition.help]
title = "Booklet Printing Help"
overview = "Booklet imposition arranges your PDF pages for professional booklet printing. Pages are reordered so when printed double-sided, folded, and stapled, they read in correct sequence."
duplexOptions = "Duplex Printing Options"
automaticDuplex = "Automatic Duplex: Enable 'Double-sided printing' - printer handles both sides automatically. Use 'Flip on short edge' if your printer requires it."
manualDuplex = "Manual Duplex (No automatic duplex printer): Turn OFF 'Double-sided printing', use 1st Pass to print fronts, then 2nd Pass to print backs after reloading paper."
gutterMargin = "Gutter Margin"
gutterDesc = "Adds extra space along the binding edge (inner margin) so text doesn't get lost in the fold. Typical values: 18-36 points (0.25-0.5 inches). Larger booklets need more gutter."
spineLocation = "Spine Location"
spineDesc = "Left (standard): For left-to-right languages (English, etc.). Right (RTL): For right-to-left languages (Arabic, Hebrew, etc.)."
borderOption = "Border Option"
borderDesc = "Adds visible borders around each page section. Useful for cutting guides or checking alignment before printing final copies."
quickSteps = "Quick Steps"
step1 = "1. Upload your PDF (must have even number of pages - tool will add blank if needed)"
step2 = "2. Choose automatic duplex (recommended) or manual duplex mode"
step3 = "3. Set gutter margin if binding/stapling (18-36 points typical)"
step4 = "4. Download and print double-sided (flip on long edge for most printers)"
step5 = "5. Fold printed sheets in half, stack in order, staple along spine"
paperSize = "Paper Size"
paperSizeDesc = "Automatically detected from your PDF. Booklet pages will be arranged 2-up on landscape-oriented sheets (e.g., two letter pages side-by-side on ledger)."
[bookletImposition.error]
failed = "An error occurred while creating the booklet imposition."
@@ -2985,6 +3137,30 @@ submit = "Submit"
3 = "Upload the single large scanned PDF file and let Stirling PDF handle the rest."
4 = "Divider pages are automatically detected and removed, guaranteeing a neat final document."
[autoSplitPDF.help]
title = "QR Code Auto-Split Help"
overview = "This tool automatically splits scanned PDFs using special QR code divider pages. Perfect for batch scanning multiple documents."
howItWorks = "How It Works"
step1 = "1. Download and print the QR code divider page (black & white is fine)"
step2 = "2. Place divider pages between your documents"
step3 = "3. Scan all documents in one batch (dividers included)"
step4 = "4. Upload to Stirling-PDF - documents are automatically separated and dividers removed"
duplexMode = "Duplex Mode (Double-Sided Scanning)"
duplexDesc = "Enable 'Duplex Mode' when scanning double-sided documents. This automatically skips the back sides of divider pages, preventing blank pages in your output."
duplexExample = "Example: With duplex ON, if you scan a divider followed by a 2-page document, the tool knows the divider's back is blank and skips it."
qrCodes = "Valid QR Codes"
qrDesc = "Only QR codes from Stirling-PDF divider pages work. These contain specific URLs: github.com/Stirling-Tools/Stirling-PDF, github.com/Frooodle/Stirling-PDF, or stirlingpdf.com"
useCases = "Best Use Cases"
useCase1 = "Batch scanning multiple contracts, forms, or reports"
useCase2 = "Digitizing physical file folders"
useCase3 = "Scanning stacks of invoices or receipts"
useCase4 = "Processing mail or paperwork in bulk"
tips = "Tips"
tip1 = "Print dividers once, reuse them for all your scanning sessions"
tip2 = "Dividers work in black & white - no need for colour printing"
tip3 = "Make sure QR codes are clearly visible (not crumpled or dirty)"
tip4 = "For best results, use the same paper weight for dividers as your documents"
[sanitizePdf]
tags = "clean,secure,safe,remove-threats"
@@ -3254,6 +3430,31 @@ placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
[redact.manual.redactionColor]
title = "Redaction Colour"
[redact.help]
title = "Redaction Help & Regex Patterns"
overview = "Overview"
overviewDesc = "Redaction permanently removes sensitive content from PDFs. Text behind redaction boxes is completely removed, not just covered. Metadata is also cleaned automatically."
regexMode = "Regular Expression (Regex) Mode"
regexDesc = "Enable 'Use Regex' to use pattern-based matching for complex searches. Powerful for finding multiple instances of similar data."
regexExamples = "Common Regex Patterns"
regexSSN = "Social Security Number: \\d{3}-\\d{2}-\\d{4} (matches XXX-XX-XXXX)"
regexPhone = "Phone Number: \\(?\\d{3}\\)?[-.]?\\d{3}[-.]?\\d{4} (matches various phone formats)"
regexEmail = "Email Address: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,} (matches email addresses)"
regexCreditCard = "Credit Card: \\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4} (matches card numbers)"
regexDate = "Date (YYYY-MM-DD): \\d{4}-\\d{2}-\\d{2}"
regexZipCode = "US ZIP Code: \\d{5}(-\\d{4})? (matches 12345 or 12345-6789)"
regexIPAddress = "IP Address: \\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"
wholeWordMode = "Whole Word Search"
wholeWordDesc = "Enable 'Whole Word Search' to match only complete words. Example: 'John' won't match 'Johnson' when enabled. Useful for names."
securityFeatures = "Security Features"
securityConvert = "Convert to PDF-Image: Converts the entire PDF to images after redaction. This ensures text is truly unrecoverable. Note: Increases file size and makes text non-selectable."
securityMetadata = "Automatic Metadata Cleaning: Automatically removes author, subject, keywords, and XMP metadata for privacy."
tips = "Best Practices"
tip1 = "Test First: Test your regex patterns on a copy before using on important documents."
tip2 = "Review Results: Always review redacted documents to ensure all sensitive information is removed."
tip3 = "Use Convert to Image: For highly sensitive documents, enable 'Convert to PDF-Image' to ensure complete removal."
tip4 = "Padding: Add custom padding to ensure redaction boxes fully cover text (useful for different font sizes)."
[tableExtraxt]
tags = "CSV,Table Extraction,extract,convert"
@@ -3373,6 +3574,35 @@ failed = "An error occurred while adding stamp to the PDF."
[AddStampRequest.results]
title = "Stamp Results"
[AddStampRequest.help]
title = "Stamp Tool Help"
overview = "Add text or image stamps to PDFs at precise locations. Supports variable substitution and multi-language fonts."
variables = "Variable Substitution (Text Stamps)"
variablesDesc = "Use these variables in your stamp text for dynamic content:"
variableN = "{n} - Current page number"
variableTotal = "{total} - Total pages"
variableFilename = "{filename} - Document filename"
variableExample = "Example: 'Document {filename} - Page {n}' becomes 'Document myfile - Page 5'"
positioning = "Positioning System"
positionGrid = "Quick Position: Use 1-9 grid (1=top-left, 5=center, 9=bottom-right) for fast placement"
positionOverride = "Override Coordinates: Enter exact X/Y pixel coordinates for precise placement (overrides grid position)"
positionMargin = "Custom Margin: Adjust distance from page edges (Small/Medium/Large/X-Large)"
multiLanguage = "Multi-Language Support"
multiLanguageDesc = "Choose alphabet/language for proper font rendering: Roman, Arabic, Japanese, Korean, Chinese, Thai. Essential for non-Latin text."
formatting = "Formatting Options"
rotation = "Rotation: -360° to 360° for angled stamps"
opacity = "Opacity: 0-100% for transparency (lower = more transparent)"
color = "Custom Colour: Choose any colour for text stamps"
fontSize = "Font/Image Size: Adjust size of text or image stamps"
stampTypes = "Stamp Types"
textStamp = "Text: Quick approval stamps ('APPROVED', 'REVIEWED', 'CONFIDENTIAL'), custom messages"
imageStamp = "Image: Upload logos, signatures, or custom graphics"
useCases = "Common Use Cases"
useCase1 = "Approval stamps: 'APPROVED', 'REVIEWED BY [NAME]', 'CONFIDENTIAL'"
useCase2 = "Business stamps: Company logos, official seals"
useCase3 = "Page references: 'Page {n} of {total}'"
useCase4 = "Document identifiers: '{filename} - {n}'"
[removeImagePdf]
tags = "Remove Image,Page operations,Back end,server side"
@@ -3703,6 +3933,16 @@ filesize = "File Size"
[compress.grayscale]
label = "Apply Grayscale for Compression"
[compress.lineArt]
label = "Convert images to line art"
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
unavailable = "ImageMagick is not installed or enabled on this server"
detailLevel = "Detail level"
edgeEmphasis = "Edge emphasis"
edgeLow = "Gentle"
edgeMedium = "Balanced"
edgeHigh = "Strong"
[compress.tooltip.header]
title = "Compress Settings Overview"
@@ -3720,9 +3960,36 @@ bullet2 = "Higher values reduce file size"
title = "Grayscale"
text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
[compress.tooltip.lineArt]
title = "Line Art"
text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected."
[compress.error]
failed = "An error occurred while compressing the PDF."
[compress.help]
title = "PDF Compression Guide"
overview = "Reduce PDF file size while balancing quality. Uses qpdf for compression and optimization."
methods = "Compression Methods"
qualityMethod = "Quality: Choose compression strength (1-9). Lower preserves quality, higher reduces size more aggressively. Recommended: 3-5 for most documents."
filesizeMethod = "File Size: Enter target size - tool automatically adjusts quality to reach it. Best when you have size limits (email attachments, etc.)."
options = "Additional Options"
grayscale = "Grayscale: Converts all colours to black & white. Dramatically reduces size for color-heavy documents. Best for: Text documents, reports, forms."
lineArt = "Line Art: Maximum compression. Converts to high-contrast black & white (requires ImageMagick). Best for: Text-only documents, technical drawings. NOT recommended for photos."
qualityLevels = "Quality Level Guide"
level1to3 = "1-3: High quality. Minimal compression. Use for important documents, presentations, or photos."
level4to6 = "4-6: Balanced. Noticeable compression with acceptable quality. Good for general use."
level7to9 = "7-9: Maximum compression. Lower quality but smallest files. Use for drafts, internal documents."
tips = "Tips"
tip1 = "Start with quality level 3-4 and increase if more compression needed"
tip2 = "Grayscale option works well for scanned documents without photos"
tip3 = "Line art is extreme - preview results before using on final documents"
tip4 = "File size method is convenient but may produce varying quality across pages"
expectations = "Size Reduction Expectations"
typical = "Typical: 20-40% reduction for standard PDFs"
images = "Image-heavy: 50-70% reduction with grayscale"
extreme = "Line art: 80-95% reduction (text becomes images)"
[compress.selectText]
2 = "Optimisation level:"
4 = "Auto mode - Auto adjusts quality to get PDF to exact size"
@@ -4038,12 +4305,20 @@ settings = "Settings"
adminSettings = "Admin Settings"
allTools = "Tools"
reader = "Reader"
tours = "Tours"
showMeAround = "Show me around"
[quickAccess.toursTooltip]
admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour."
user = "Watch walkthroughs here: Tools tour and the New V2 layout tour."
[quickAccess.helpMenu]
toolsTour = "Tools Tour"
toolsTourDesc = "Learn what the tools can do"
adminTour = "Admin Tour"
adminTourDesc = "Explore admin settings & features"
whatsNewTour = "See what's new in V2"
whatsNewTourDesc = "Tour the updated layout"
[admin]
error = "Error"
@@ -4755,6 +5030,8 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
mobileUpload = "Mobile Upload"
mobileShort = "Mobile"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
@@ -5070,6 +5347,7 @@ loading = "Loading..."
back = "Back"
continue = "Continue"
error = "Error"
save = "Save"
[config.overview]
title = "Application Configuration"
@@ -5236,6 +5514,16 @@ finish = "Finish"
startTour = "Start Tour"
startTourDescription = "Take a guided tour of Stirling PDF's key features"
[onboarding.whatsNew]
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace."
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
activeFilesView = "Use Active Files to see everything you have open and pick what to work on."
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
[onboarding.welcomeModal]
title = "Welcome to Stirling PDF!"
description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
@@ -5256,6 +5544,10 @@ download = "Download →"
showMeAround = "Show me around"
skipTheTour = "Skip the tour"
[onboarding.tourOverview]
title = "Tour Overview"
body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
[onboarding.serverLicense]
skip = "Skip for now"
seePlans = "See Plans →"
@@ -5794,6 +6086,8 @@ notAvailable = "Audit system not available"
notAvailableMessage = "The audit system is not configured or not available."
disabled = "Audit logging is disabled"
disabledMessage = "Enable audit logging in your application configuration to track system events."
enterpriseRequired = "Enterprise License Required"
enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics."
[audit.error]
title = "Error loading audit system"
@@ -6187,3 +6481,42 @@ title = "Add Text Results"
[addText.error]
failed = "An error occurred while adding text to the PDF."
[mobileUpload]
title = "Upload from Mobile"
description = "Scan this QR code with your mobile device to upload photos directly to this page."
error = "Connection Error"
pollingError = "Error checking for files"
sessionId = "Session ID"
filesReceived = "{{count}} file(s) received"
connected = "Mobile device connected"
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
[mobileScanner]
title = "Mobile Scanner"
noSession = "Invalid Session"
noSessionMessage = "Please scan a valid QR code to access this page."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
connected = "Connected"
connecting = "Connecting..."
camera = "Camera"
fileUpload = "File Upload"
cameraAccessDenied = "Camera access denied. Please enable camera access."
liveDetection = "Live Detection"
autoEnhance = "Auto-enhance"
autoEnhanceInfo = "Document edges will be automatically detected and perspective corrected"
flashlight = "Flashlight"
processing = "Processing..."
capture = "Capture Photo"
selectImage = "Select Image"
preview = "Preview"
retake = "Retake"
addToBatch = "Add to Batch"
upload = "Upload"
batchImages = "Batch"
clearBatch = "Clear"
uploadAll = "Upload All"
+263
View File
@@ -0,0 +1,263 @@
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jscanify = factory());
})(this, function () {
"use strict";
/**
* Calculates distance between two points. Each point must have `x` and `y` property
* @param {*} p1 point 1
* @param {*} p2 point 2
* @returns distance between two points
*/
function distance(p1, p2) {
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
class jscanify {
constructor() { }
/**
* Finds the contour of the paper within the image
* @param {*} img image to process (cv.Mat)
* @returns the biggest contour inside the image
*/
findPaperContour(img) {
const imgGray = new cv.Mat();
cv.Canny(img, imgGray, 50, 200);
const imgBlur = new cv.Mat();
cv.GaussianBlur(
imgGray,
imgBlur,
new cv.Size(3, 3),
0,
0,
cv.BORDER_DEFAULT
);
const imgThresh = new cv.Mat();
cv.threshold(
imgBlur,
imgThresh,
0,
255,
cv.THRESH_OTSU
);
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
cv.findContours(
imgThresh,
contours,
hierarchy,
cv.RETR_CCOMP,
cv.CHAIN_APPROX_SIMPLE
);
let maxArea = 0;
let maxContourIndex = -1;
for (let i = 0; i < contours.size(); ++i) {
let contourArea = cv.contourArea(contours.get(i));
if (contourArea > maxArea) {
maxArea = contourArea;
maxContourIndex = i;
}
}
const maxContour =
maxContourIndex >= 0 ?
contours.get(maxContourIndex) :
null;
imgGray.delete();
imgBlur.delete();
imgThresh.delete();
contours.delete();
hierarchy.delete();
return maxContour;
}
/**
* Highlights the paper detected inside the image.
* @param {*} image image to process
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
* @returns `HTMLCanvasElement` with original image and paper highlighted
*/
highlightPaper(image, options) {
options = options || {};
options.color = options.color || "orange";
options.thickness = options.thickness || 10;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = cv.imread(image);
const maxContour = this.findPaperContour(img);
cv.imshow(canvas, img);
if (maxContour) {
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = this.getCornerPoints(maxContour, img);
if (
topLeftCorner &&
topRightCorner &&
bottomLeftCorner &&
bottomRightCorner
) {
ctx.strokeStyle = options.color;
ctx.lineWidth = options.thickness;
ctx.beginPath();
ctx.moveTo(...Object.values(topLeftCorner));
ctx.lineTo(...Object.values(topRightCorner));
ctx.lineTo(...Object.values(bottomRightCorner));
ctx.lineTo(...Object.values(bottomLeftCorner));
ctx.lineTo(...Object.values(topLeftCorner));
ctx.stroke();
}
}
img.delete();
return canvas;
}
/**
* Extracts and undistorts the image detected within the frame.
*
* Returns `null` if no paper is detected.
*
* @param {*} image image to process
* @param {*} resultWidth desired result paper width
* @param {*} resultHeight desired result paper height
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
* @returns `HTMLCanvasElement` containing undistorted image
*/
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
const canvas = document.createElement("canvas");
const img = cv.imread(image);
const maxContour = cornerPoints ? null : this.findPaperContour(img);
if(maxContour == null && cornerPoints === undefined){
return null;
}
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = cornerPoints || this.getCornerPoints(maxContour, img);
let warpedDst = new cv.Mat();
let dsize = new cv.Size(resultWidth, resultHeight);
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
topLeftCorner.x,
topLeftCorner.y,
topRightCorner.x,
topRightCorner.y,
bottomLeftCorner.x,
bottomLeftCorner.y,
bottomRightCorner.x,
bottomRightCorner.y,
]);
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
0,
0,
resultWidth,
0,
0,
resultHeight,
resultWidth,
resultHeight,
]);
let M = cv.getPerspectiveTransform(srcTri, dstTri);
cv.warpPerspective(
img,
warpedDst,
M,
dsize,
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar()
);
cv.imshow(canvas, warpedDst);
img.delete()
warpedDst.delete()
return canvas;
}
/**
* Calculates the corner points of a contour.
* @param {*} contour contour from {@link findPaperContour}
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
*/
getCornerPoints(contour) {
let rect = cv.minAreaRect(contour);
const center = rect.center;
let topLeftCorner;
let topLeftCornerDist = 0;
let topRightCorner;
let topRightCornerDist = 0;
let bottomLeftCorner;
let bottomLeftCornerDist = 0;
let bottomRightCorner;
let bottomRightCornerDist = 0;
for (let i = 0; i < contour.data32S.length; i += 2) {
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
const dist = distance(point, center);
if (point.x < center.x && point.y < center.y) {
// top left
if (dist > topLeftCornerDist) {
topLeftCorner = point;
topLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y < center.y) {
// top right
if (dist > topRightCornerDist) {
topRightCorner = point;
topRightCornerDist = dist;
}
} else if (point.x < center.x && point.y > center.y) {
// bottom left
if (dist > bottomLeftCornerDist) {
bottomLeftCorner = point;
bottomLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y > center.y) {
// bottom right
if (dist > bottomRightCornerDist) {
bottomRightCorner = point;
bottomRightCornerDist = dist;
}
}
}
return {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
};
}
}
return jscanify;
});
File diff suppressed because one or more lines are too long
+93
View File
@@ -589,6 +589,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -716,6 +736,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -908,6 +934,15 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -1626,6 +1661,12 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -2820,6 +2861,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3651,6 +3702,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
@@ -4256,6 +4317,7 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
@@ -4615,6 +4677,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.4"
@@ -4735,6 +4818,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
@@ -4954,6 +5038,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
+2 -1
View File
@@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0"
tauri-plugin-deep-link = "2.4.5"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] }
+3 -1
View File
@@ -19,6 +19,8 @@
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
},
"opener:default",
"shell:allow-open"
]
}
+36 -1
View File
@@ -1,4 +1,4 @@
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
mod utils;
mod commands;
@@ -28,6 +28,17 @@ use commands::{
};
use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs};
use tauri_plugin_deep_link::DeepLinkExt;
fn dispatch_deep_link(app: &AppHandle, url: &str) {
add_log(format!("🔗 Dispatching deep link: {}", url));
let _ = app.emit("deep-link", url.to_string());
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -42,6 +53,7 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
@@ -78,6 +90,29 @@ pub fn run() {
}
}
{
let app_handle = app.handle();
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
#[cfg(any(target_os = "linux", target_os = "windows"))]
if let Err(err) = app_handle.deep_link().register_all() {
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
}
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
let initial_handle = app_handle.clone();
for url in urls {
dispatch_deep_link(&initial_handle, url.as_str());
}
}
let event_app_handle = app_handle.clone();
app_handle.deep_link().on_open_url(move |event| {
for url in event.urls() {
dispatch_deep_link(&event_app_handle, url.as_str());
}
});
}
// Start backend immediately, non-blocking
let app_handle = app.handle().clone();
+84 -69
View File
@@ -1,74 +1,89 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.0.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --mode desktop"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "app", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"description": "Open PDF files with Stirling-PDF",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.1.3",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --mode desktop"
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
"bundle": {
"active": true,
"targets": [
"deb",
"rpm",
"dmg",
"app",
"msi"
],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": [
"pdf"
],
"name": "PDF Document",
"description": "Open PDF files with Stirling-PDF",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": [
"stirlingpdf"
]
}
}
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
}
}
}
+39 -6
View File
@@ -1,8 +1,12 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@@ -13,8 +12,12 @@ interface TextInputWithFontProps {
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
label: string;
placeholder: string;
fontLabel: string;
fontSizeLabel: string;
fontSizePlaceholder: string;
colorLabel?: string;
onAnyChange?: () => void;
}
@@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
disabled = false,
label,
placeholder,
fontLabel,
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
label={label}
placeholder={placeholder}
value={text}
onChange={(e) => {
onTextChange(e.target.value);
@@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label={t('sign.text.fontLabel', 'Font')}
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
@@ -107,8 +113,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
label={fontSizeLabel}
placeholder={fontSizePlaceholder}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@@ -155,7 +161,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label={t('sign.text.colorLabel', 'Text colour')}
label={colorLabel}
value={colorInput}
placeholder="#000000"
disabled={disabled}
@@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -1,12 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import CloudIcon from '@mui/icons-material/Cloud';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const UploadIcon = icons.upload;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const handleGoogleDriveClick = async () => {
try {
@@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = (files: File[]) => {
if (files.length > 0) {
onNewFilesSelect(files);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
@@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
}}
>
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<>
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<>
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
};
@@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
}
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
@@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}
@@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
@@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience';
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
export default function Onboarding() {
@@ -39,6 +40,11 @@ export default function Onboarding() {
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
const { config, refetch: refetchConfig } = useAppConfig();
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
@@ -50,7 +56,34 @@ export default function Onboarding() {
window.location.href = '/login';
}, [actions]);
const handleButtonAction = useCallback((action: ButtonAction) => {
// Check if we should show analytics modal before onboarding
useEffect(() => {
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
setShowAnalyticsModal(true);
}
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
setAnalyticsLoading(false);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
const handleButtonAction = useCallback(async (action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
@@ -69,35 +102,43 @@ export default function Onboarding() {
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
actions.complete();
actions.updateRuntimeState({ tourType: 'admin' });
setIsTourOpen(true);
break;
case 'launch-tools':
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.complete();
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
actions.updateRuntimeState({ tourRequested: true, tourType });
actions.complete();
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
}
case 'skip-to-license':
markStepSeen('tour');
actions.updateRuntimeState({ tourRequested: false });
actions.complete();
break;
case 'skip-tour':
actions.complete();
break;
case 'see-plans':
actions.complete();
navigate('/settings/adminPlan');
break;
case 'enable-analytics':
await handleAnalyticsChoice(true);
break;
case 'disable-analytics':
await handleAnalyticsChoice(false);
break;
}
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const [isTourOpen, setIsTourOpen] = useState(false);
@@ -117,10 +158,7 @@ export default function Onboarding() {
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
@@ -130,6 +168,24 @@ export default function Onboarding() {
[t, tourOrch, closeFilesModal, openFilesModal]
);
const whatsNewStepsConfig = useMemo(
() => createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
);
const adminStepsConfig = useMemo(
() => createAdminStepsConfig({
t,
@@ -144,21 +200,19 @@ export default function Onboarding() {
);
const tourSteps = useMemo<StepType[]>(() => {
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
useEffect(() => {
if (currentStep?.id === 'tour' && !isTourOpen) {
markStepSeen('tour');
setIsTourOpen(true);
switch (runtimeState.tourType) {
case 'admin':
return Object.values(adminStepsConfig);
case 'whatsnew':
return Object.values(whatsNewStepsConfig);
default:
return Object.values(userStepsConfig);
}
}, [currentStep, isTourOpen, activeFlow]);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
useEffect(() => {
if (externalTourRequested) {
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
markStepSeen('tour');
actions.updateRuntimeState({ tourType: requestedTourType });
setIsTourOpen(true);
clearTourRequest();
}
@@ -176,9 +230,9 @@ export default function Onboarding() {
} else {
tourOrch.restoreWorkbenchState();
}
markStepSeen('tour');
if (currentStep?.id === 'tour') actions.complete();
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
// Advance to next onboarding step after tour completes
actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
@@ -216,8 +270,10 @@ export default function Onboarding() {
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
});
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
@@ -237,8 +293,45 @@ export default function Onboarding() {
return null;
}
// Show analytics modal before onboarding if needed
if (showAnalyticsModal) {
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
analyticsError,
analyticsLoading,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}} // No skip allowed
onAction={async (action) => {
if (action === 'enable-analytics') {
await handleAnalyticsChoice(true);
} else if (action === 'disable-analytics') {
await handleAnalyticsChoice(false);
}
}}
allowDismiss={false}
/>
);
}
if (showLicenseSlide) {
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
// Remove back button for external license notice
const slideDefinition = {
...baseSlideDefinition,
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
};
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
@@ -250,7 +343,7 @@ export default function Onboarding() {
licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
@@ -271,40 +364,34 @@ export default function Onboarding() {
);
}
// Always render the tour component (it controls its own visibility with isOpen)
const tourComponent = (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
// If no active onboarding, just show the tour (which may or may not be open)
if (isLoading || !isActive || !currentStep) {
return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
return tourComponent;
}
// If tour is open, hide the onboarding modal and just show the tour
if (isTourOpen) {
return tourComponent;
}
// Render the current onboarding step
switch (currentStep.type) {
case 'tool-prompt':
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'tour':
return (
<OnboardingTour
isOpen={true}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
case 'analytics-modal':
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
@@ -6,8 +6,9 @@
*/
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import { Modal, Stack, ActionIcon } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import CloseIcon from '@mui/icons-material/Close';
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
@@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
allowDismiss?: boolean;
}
export default function OnboardingModalSlide({
@@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
currentModalSlideIndex,
onSkip,
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const renderHero = () => {
@@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
{slideDefinition.hero.type === 'lock' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'analytics' && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
opened={true}
onClose={onSkip}
closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered
size="lg"
radius="lg"
@@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
isActive
slideKey={slideContent.key}
/>
{allowDismiss && (
<ActionIcon
onClick={onSkip}
radius="md"
size={36}
style={{
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
},
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
{modalSlideCount > 1 && (
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
tourType: 'admin' | 'tools' | 'whatsnew';
isRTL: boolean;
t: TFunction;
isOpen: boolean;
@@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
@@ -12,9 +14,11 @@ export type SlideId =
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
| 'server-license'
| 'tour-overview'
| 'analytics-choice';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
export type ButtonAction =
| 'next'
@@ -27,7 +31,10 @@ export type ButtonAction =
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license';
| 'skip-to-license'
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
@@ -52,6 +59,8 @@ export interface SlideFactoryParams {
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
}
export interface HeroDefinition {
@@ -79,9 +88,9 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
@@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'license-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'license-close',
type: 'button',
@@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
],
},
'tour-overview': {
id: 'tour-overview',
createSlide: () => TourOverviewSlide(),
hero: { type: 'rocket' },
buttons: [
{
key: 'tour-overview-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'tour-overview-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'skip-tour',
},
{
key: 'tour-overview-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-tools',
},
],
},
'analytics-choice': {
id: 'analytics-choice',
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
hero: { type: 'analytics' },
buttons: [
{
key: 'analytics-disable',
type: 'button',
label: 'no',
variant: 'secondary',
group: 'left',
action: 'disable-analytics',
},
{
key: 'analytics-enable',
type: 'button',
label: 'yes',
variant: 'primary',
group: 'right',
action: 'enable-analytics',
},
],
},
};
@@ -5,21 +5,20 @@ export type OnboardingStepId =
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour'
| 'tour-overview'
| 'server-license'
| 'analytics-choice';
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt'
| 'tour'
| 'analytics-modal';
| 'tool-prompt';
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
tourRequested: boolean;
tourType: 'admin' | 'tools';
tourType: 'admin' | 'tools' | 'whatsnew';
isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean;
analyticsEnabled: boolean;
licenseNotice: {
@@ -42,13 +41,13 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'tools',
tourType: 'whatsnew',
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
@@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
requiresPasswordChange: false,
firstLoginUsername: '',
usingDefaultCredentials: false,
desktopSlideEnabled: true,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
@@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'welcome',
condition: () => true,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
@@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => true,
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: 'tour',
type: 'tour',
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: () => false,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => false,
},
{
id: 'tour-overview',
type: 'modal-slide',
slideId: 'tour-overview',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
},
{
id: 'server-license',
@@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'analytics-choice',
type: 'analytics-modal',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -1,94 +1,71 @@
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
const STORAGE_PREFIX = 'onboarding';
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function getStorageKey(stepId: OnboardingStepId): string {
return `${STORAGE_PREFIX}::${stepId}`;
}
export function hasSeenStep(stepId: OnboardingStepId): boolean {
export function isOnboardingCompleted(): boolean {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(getStorageKey(stepId)) === 'true';
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
} catch {
return false;
}
}
export function markStepSeen(stepId: OnboardingStepId): void {
export function markOnboardingCompleted(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(getStorageKey(stepId), 'true');
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking step as seen:', error);
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
}
}
export function resetStepSeen(stepId: OnboardingStepId): void {
export function resetOnboardingProgress(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(stepId));
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error('[onboardingStorage] Error resetting step seen:', error);
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
}
}
export function resetAllOnboardingProgress(): void {
if (typeof window === 'undefined') return;
export function hasShownToursTooltip(): boolean {
if (typeof window === 'undefined') return false;
try {
const prefix = `${STORAGE_PREFIX}::`;
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(prefix)) keysToRemove.push(key);
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
} catch {
return false;
}
}
export function getOnboardingStorageState(): Record<string, boolean> {
const state: Record<string, boolean> = {};
ONBOARDING_STEPS.forEach((step) => {
state[step.id] = hasSeenStep(step.id);
});
return state;
export function markToursTooltipShown(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
}
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// Migrate based on legacy flags
if (prefs.hasSeenIntroOnboarding === true) {
markStepSeen('welcome');
markStepSeen('desktop-install');
markStepSeen('security-check');
markStepSeen('admin-overview');
// If user had completed onboarding in old system, mark new system as complete
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
markOnboardingCompleted();
}
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
markStepSeen('tool-layout');
}
if (prefs.hasCompletedOnboarding === true) {
markStepSeen('tour');
markStepSeen('analytics-choice');
markStepSeen('server-license');
}
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
} catch {
@@ -12,8 +12,8 @@ import {
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
import {
hasSeenStep,
markStepSeen,
isOnboardingCompleted,
markOnboardingCompleted,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
@@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
if (typeof window === 'undefined') {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
? sessionTourType
: 'whatsnew';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
return {
...baseState,
tourRequested,
@@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
@@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE);
@@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [isPaused, setIsPaused] = useState(false);
@@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
...prev,
analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null,
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
licenseNotice: {
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
@@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
@@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined
);
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
if (configLoading || !adminStatusResolved) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
const isFirstLoginStep = activeFlow[i].id === 'first-login';
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
if (shouldTreatAsUnseen) {
firstUnseenIndex = i;
break;
}
}
// Force reset index when password change is required (overrides initialIndexSet)
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
// If there are no steps to show, mark initialized/completed baseline
if (activeFlow.length === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
} else if (firstUnseenIndex === -1) {
return;
}
// If onboarding has been completed, don't show it
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
return;
}
// Start from the beginning
if (!initialIndexSet.current) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
const totalSteps = activeFlow.length;
const allStepsAlreadySeen = useMemo(() => {
if (activeFlow.length === 0) return false;
return activeFlow.every(step => hasSeenStep(step.id));
}, [activeFlow]);
const isComplete = isInitialized && initialIndexSet.current &&
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
? activeFlow[currentStepIndex]
const isComplete = isInitialized &&
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
? activeFlow[currentStepIndex]
: null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
@@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
}, [isComplete]);
const next = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []);
const skip = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
// Skip marks the entire onboarding as completed
markOnboardingCompleted();
setCurrentStepIndex(totalSteps);
}, [totalSteps]);
const complete = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
useEffect(() => {
if (!currentStep || isLoading) {
return;
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
// Special case: never auto-complete first-login step if requiresPasswordChange is true
const isFirstLoginStep = currentStep.id === 'first-login';
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
@@ -0,0 +1,55 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@mantine/core';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import i18n from '@app/i18n';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface AnalyticsChoiceSlideProps {
analyticsError?: string | null;
}
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
return {
key: 'analytics-choice',
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
body: (
<div className={styles.bodyCopyInner}>
<Trans
i18nKey="analytics.paragraph1"
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
components={{ strong: <strong /> }}
/>
<br />
<Trans
i18nKey="analytics.paragraph2"
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
components={{ strong: <strong /> }}
/>
<br />
<div style={{ textAlign: 'right', marginTop: 0 }}>
<Button
variant="default"
size="sm"
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
</Button>
</div>
{analyticsError && (
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
{analyticsError}
</div>
)}
</div>
),
background: {
gradientStops: ['#0EA5E9', '#6366F1'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,27 @@
import React from 'react';
import { Trans } from 'react-i18next';
import i18n from '@app/i18n';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
export default function TourOverviewSlide(): SlideConfig {
return {
key: 'tour-overview',
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
body: (
<span className={styles.bodyCopyInner}>
<Trans
i18nKey="onboarding.tourOverview.body"
defaults="Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
components={{ strong: <strong /> }}
/>
</span>
),
background: {
gradientStops: ['#2563EB', '#7C3AED'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding';
@@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
/**
* Detects the `bypassOnboarding` query parameter and stores it in session storage
* so that onboarding remains disabled while the app is open. Also marks all steps
* as seen to ensure any dependent UI elements remain hidden.
* so that onboarding remains disabled while the app is open. Also marks onboarding
* as completed to ensure any dependent UI elements remain hidden.
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const stepsMarkedRef = useRef(false);
const shouldBypassFromSearch = useMemo(() => {
try {
@@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
setBypassOnboarding(nextBypass);
if (nextBypass) {
setStoredBypass(true);
markOnboardingCompleted();
}
}, [shouldBypassFromSearch]);
useEffect(() => {
if (!bypassOnboarding || stepsMarkedRef.current) return;
stepsMarkedRef.current = true;
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
}, [bypassOnboarding]);
return bypassOnboarding;
}
@@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'tools');
setRequestedTourType(detail?.tourType ?? 'whatsnew');
setTourRequested(true);
};
@@ -8,12 +8,8 @@ export enum TourStep {
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
@@ -28,10 +24,7 @@ interface UserStepActions {
backToAllTools: () => void;
selectCropTool: () => void;
loadSampleFile: () => void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
pinFile: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
@@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
@@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
@@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
@@ -0,0 +1,197 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
if (typeof document === 'undefined') return;
const start = Date.now();
// Immediate hit
if (document.querySelector(selector)) return;
return new Promise((resolve) => {
const check = () => {
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
resolve();
return;
}
setTimeout(check, intervalMs);
};
check();
});
}
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
if (typeof document === 'undefined') return;
const start = Date.now();
return new Promise((resolve) => {
const check = () => {
const el = document.querySelector<HTMLElement>(selector);
const isVisible = !!el && el.getClientRects().length > 0;
if (isVisible || Date.now() - start >= timeoutMs) {
// Nudge Reactour to recalc positions in case layout shifted
window.dispatchEvent(new Event('resize'));
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
resolve();
return;
}
setTimeout(check, intervalMs);
};
check();
});
}
export enum WhatsNewTourStep {
QUICK_ACCESS,
LEFT_PANEL,
FILE_UPLOAD,
RIGHT_RAIL,
TOP_BAR,
PAGE_EDITOR_VIEW,
ACTIVE_FILES_VIEW,
WRAP_UP,
}
interface WhatsNewStepActions {
saveWorkbenchState: () => void;
closeFilesModal: () => void;
backToAllTools: () => void;
openFilesModal: () => void;
loadSampleFile: () => Promise<void> | void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
}
interface CreateWhatsNewStepsConfigArgs {
t: TFunction;
actions: WhatsNewStepActions;
}
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
openFilesModal,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
} = actions;
return {
[WhatsNewTourStep.QUICK_ACCESS]: {
selector: '[data-tour="quick-access-bar"]',
content: t(
'onboarding.whatsNew.quickAccess',
'Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.'
),
position: 'right',
padding: 10,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[WhatsNewTourStep.LEFT_PANEL]: {
selector: '[data-tour="tool-panel"]',
content: t(
'onboarding.whatsNew.leftPanel',
'The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.'
),
position: 'center',
padding: 0,
},
[WhatsNewTourStep.FILE_UPLOAD]: {
selector: '[data-tour="files-button"]',
content: t(
'onboarding.whatsNew.fileUpload',
'Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.'
),
position: 'right',
padding: 10,
action: async () => {
openFilesModal();
await waitForElement('[data-tour="file-sources"]', 5000, 100);
},
actionAfter: async () => {
await Promise.resolve(loadSampleFile());
closeFilesModal();
switchToViewer();
// wait for file render and top controls to mount
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
},
},
[WhatsNewTourStep.RIGHT_RAIL]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t(
'onboarding.whatsNew.rightRail',
'The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.'
),
position: 'left',
padding: 10,
action: async () => {
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
selectFirstFile();
},
},
[WhatsNewTourStep.TOP_BAR]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.topBar',
'The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
),
position: 'bottom',
padding: 8,
// Ensure the switcher has mounted before this step renders
action: async () => {
switchToViewer();
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
},
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.pageEditorView',
'Switch to the Page Editor to reorder, rotate, or delete pages.'
),
position: 'bottom',
padding: 8,
action: async () => {
switchToPageEditor();
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
},
},
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.activeFilesView',
'Use Active Files to see everything you have open and pick what to work on.'
),
position: 'bottom',
padding: 8,
action: async () => {
switchToActiveFiles();
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
},
},
[WhatsNewTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t(
'onboarding.whatsNew.wrapUp',
'That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
),
position: 'right',
padding: 10,
},
};
}
@@ -1,139 +0,0 @@
import {
Modal,
Stack,
Button,
Text,
Title,
Anchor,
useMantineTheme,
useComputedColorScheme,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const theme = useMantineTheme();
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computedColorScheme === 'dark';
const privacyHighlightStyles = {
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
borderRadius: theme.radius.md,
fontWeight: 700,
textAlign: 'center' as const,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing.xs,
letterSpacing: 0.3,
};
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Refetch config to apply new settings without page reload
await refetch();
// Close the modal after successful save
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
</Text>
<Text size="sm" style={privacyHighlightStyles}>
{t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')}
</Text>
<Text size="sm" c="dimmed">
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
<Anchor
href="https://docs.stirlingpdf.com/analytics-telemetry"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('analytics.learnMore', 'Learn more')}
</Anchor>
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.enable', 'Enable analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.disable', 'Disable analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
</Text>
</Stack>
</Modal>
);
}
@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
interface AllToolsNavButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
}
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton,
setActiveButton,
tooltipPosition = 'right'
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
@@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
};
return (
<Tooltip
content={t("quickAccess.allTools", "Tools")}
position={tooltipPosition}
arrow
containerStyle={{ marginTop: "-1rem" }}
maxWidth={200}
>
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
</Tooltip>
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
);
};
@@ -69,7 +69,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}), []);
// Get isAdmin and runningEE from app config
const isAdmin = true // config?.isAdmin ?? false;
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;
@@ -95,6 +95,22 @@ export default function Footer({
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://discord.gg/Cn8pWhQRxZ"
>
{t('footer.discord', 'Discord')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Stirling-Tools/Stirling-PDF"
>
{t('footer.issues', 'GitHub')}
</a>
<a
className="footer-link px-3"
target="_blank"
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
@@ -11,6 +11,8 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -24,6 +26,7 @@ const LandingPage = () => {
const { wordmark } = useLogoAssets();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
@@ -48,6 +51,16 @@ const LandingPage = () => {
event.target.value = '';
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = async (files: File[]) => {
if (files.length > 0) {
await addFiles(files);
}
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
@@ -202,32 +215,58 @@ const LandingPage = () => {
</span>
)}
</Button>
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
color: 'var(--accent-interactive)',
}}
>
<PhonelinkIcon />
</ActionIcon>
</Tooltip>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: 'calc(100% - 38px - 0.6rem)',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
color: 'var(--accent-interactive)',
}}
>
<PhonelinkIcon />
</ActionIcon>
</Tooltip>
</>
)}
</div>
@@ -251,6 +290,11 @@ const LandingPage = () => {
</span>
</div>
</Dropzone>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</Container>
);
};
@@ -173,7 +173,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: `${name} (${code})`,
label: name,
}));
// Hide the language selector if there's only one language option
@@ -0,0 +1,208 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Group, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useFrontendUrl } from '@app/hooks/useFrontendUrl';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
interface MobileUploadModalProps {
opened: boolean;
onClose: () => void;
onFilesReceived: (files: File[]) => void;
}
// Generate a UUID-like session ID
function generateSessionId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* MobileUploadModal
*
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
const { t } = useTranslation();
const frontendUrl = useFrontendUrl();
const [sessionId] = useState(() => generateSessionId());
const [filesReceived, setFilesReceived] = useState(0);
const [error, setError] = useState<string | null>(null);
const pollIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
const pollForFiles = useCallback(async () => {
if (!opened) return;
try {
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to check for files');
}
const data = await response.json();
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await fetch(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
);
if (downloadResponse.ok) {
const blob = await downloadResponse.blob();
const file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
});
processedFiles.current.add(fileMetadata.filename);
setFilesReceived((prev) => prev + 1);
onFilesReceived([file]);
}
} catch (err) {
console.error('Failed to download file:', fileMetadata.filename, err);
}
}
// Delete the entire session immediately after downloading all files
// This ensures files are only on server for ~1 second
try {
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
console.log('Session cleaned up after file download');
} catch (cleanupErr) {
console.warn('Failed to cleanup session after download:', cleanupErr);
}
}
} catch (err) {
console.error('Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
}
}, [opened, sessionId, onFilesReceived, t]);
// Start polling when modal opens
useEffect(() => {
if (opened) {
setFilesReceived(0);
setError(null);
processedFiles.current.clear();
// Poll every 2 seconds
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
// Initial poll
pollForFiles();
} else {
// Stop polling when modal closes
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [opened, pollForFiles]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
centered
size="md"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Text size="sm">
{t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos directly to this page.'
)}
</Text>
</Alert>
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
<Group gap="xs">
<Text size="sm" c="dimmed">
{t('mobileUpload.sessionId', 'Session ID')}:
</Text>
<Badge variant="light" color="blue" size="lg">
{sessionId}
</Badge>
</Group>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
{t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
)}
</Text>
<Text
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{mobileUrl}
</Text>
</Box>
</Stack>
</Modal>
);
}
@@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
import { Tooltip } from '@app/components/shared/Tooltip';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
@@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
import {
isNavButtonActive,
@@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const {
tooltipOpen,
manualCloseOnly,
showCloseButton,
toursMenuOpen,
setToursMenuOpen,
handleTooltipOpenChange,
} = useToursTooltip();
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
@@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
name: t("quickAccess.tours", "Tours"),
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'md',
type: 'action',
@@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
data-tour="quick-access-bar"
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
>
{/* Fixed header outside scrollable area */}
@@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Handle help button with menu or direct action
if (buttonConfig.id === 'help') {
const isAdmin = config?.isAdmin === true;
const toursTooltipContent = isAdmin
? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.')
: t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.');
const tourItems = [
{
key: 'whatsnew',
icon: <LocalIcon icon="auto-awesome-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"),
description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"),
onClick: () => requestStartTour('whatsnew'),
},
{
key: 'tools',
icon: <LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"),
description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"),
onClick: () => requestStartTour('tools'),
},
...(isAdmin ? [{
key: 'admin',
icon: <LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.adminTour", "Admin Tour"),
description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"),
onClick: () => requestStartTour('admin'),
}] : []),
];
// If not admin, just show button that starts tools tour directly
if (!isAdmin) {
return (
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => requestStartTour('tools')}
const helpButtonNode = (
<div data-tour="help-button">
<Menu
position={isRTL ? 'left' : 'right'}
offset={10}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
opened={toursMenuOpen}
onChange={setToursMenuOpen}
>
{renderNavButton(buttonConfig, index)}
</div>
);
}
// If admin, show menu with both options
return (
<div key={buttonConfig.id} data-tour="help-button">
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
<Menu.Target>
<div>{renderNavButton(buttonConfig, index)}</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('tools')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
{tourItems.map((item) => (
<Menu.Item
key={item.key}
leftSection={item.icon}
onClick={item.onClick}
>
<div>
<div style={{ fontWeight: 500 }}>
{item.title}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{item.description}
</div>
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
</div>
</div>
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('admin')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
</div>
</div>
</Menu.Item>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</div>
);
return (
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
);
}
const buttonNode = renderNavButton(buttonConfig, index);
+20 -11
View File
@@ -33,6 +33,10 @@ export interface TooltipProps {
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
/** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */
manualCloseOnly?: boolean;
/** Show a close button even when not pinned */
showCloseButton?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
closeOnOutside = true,
disabled = false,
openOnFocus = true,
manualCloseOnly = false,
showCloseButton = false,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
@@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const allowAutoClose = !manualCloseOnly;
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
@@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
[isPinned, closeOnOutside, setOpen, allowAutoClose]
);
useEffect(() => {
@@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
clearTimers();
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
);
const handleMouseDown = useCallback(
@@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
return;
}
clearTimers();
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props, clearTimers]
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (manualCloseOnly) return;
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
}, [setOpen, manualCloseOnly]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
@@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
},
[isPinned, setOpen]
[isPinned, setOpen, allowAutoClose]
);
// Enhance child with handlers and ref
@@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
});
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
const tooltipElement = shouldShowTooltip ? (
<div
@@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
{shouldShowCloseButton && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
@@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} />
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
</div>
) : null;
@@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [
'preferences',
'notifications',
'connections',
'account',
'general',
'people',
'teams',
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
export interface ToursTooltipState {
tooltipOpen: boolean | undefined;
manualCloseOnly: boolean;
showCloseButton: boolean;
toursMenuOpen: boolean;
setToursMenuOpen: (open: boolean) => void;
handleTooltipOpenChange: (next: boolean) => void;
}
/**
* Encapsulates all the logic for the tours tooltip:
* - Shows automatically after onboarding/tour completes (once per user)
* - Hides while the tours menu is open
* - After dismissal, reverts to hover-only tooltip
*/
export function useToursTooltip(): ToursTooltipState {
const [showToursTooltip, setShowToursTooltip] = useState(false);
const [toursMenuOpen, setToursMenuOpen] = useState(false);
const tourWasOpenRef = useRef(false);
// Auto-show when a tour ends (fires once per user)
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourStateChange = (event: Event) => {
const { detail } = event as CustomEvent<TourStatePayload>;
const wasOpen = tourWasOpenRef.current;
tourWasOpenRef.current = detail.isOpen;
if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) {
setShowToursTooltip(true);
}
};
window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
}, []);
// Show once after onboarding is complete
useEffect(() => {
if (isOnboardingCompleted() && !hasShownToursTooltip()) {
setShowToursTooltip(true);
}
}, []);
const handleDismissToursTooltip = useCallback(() => {
markToursTooltipShown();
setShowToursTooltip(false);
}, []);
const hasBeenDismissed = hasShownToursTooltip();
const handleTooltipOpenChange = useCallback(
(next: boolean) => {
if (!next) {
if (!hasBeenDismissed) {
handleDismissToursTooltip();
}
} else if (!hasBeenDismissed && !toursMenuOpen) {
setShowToursTooltip(true);
}
},
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
);
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
return {
tooltipOpen,
manualCloseOnly: !hasBeenDismissed,
showCloseButton: !hasBeenDismissed,
toursMenuOpen,
setToursMenuOpen,
handleTooltipOpenChange,
};
}
@@ -1,15 +1,9 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import React from 'react';
import { ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile } from '@app/contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
@@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
@@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
disabled={disabled || currentView !== 'viewer'}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -74,164 +50,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -39,7 +39,7 @@
background: var(--bg-raised);
padding: 0.25rem;
border-radius: 0.25rem;
border: 0.0625rem solid var(--primary-color, #3b82f6);
border: 0.0625rem solid var(--border-default);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1;
@@ -60,6 +60,13 @@
border-color: #ef4444 !important;
}
.tooltip-pin-button:focus,
.tooltip-pin-button:focus-visible {
outline: none;
border-color: var(--border-default) !important;
background-color: var(--bg-raised) !important;
}
/* Tooltip Header */
.tooltip-header {
display: flex;
@@ -91,7 +98,7 @@
/* Tooltip Body */
.tooltip-body {
padding: 1rem !important;
padding: 1rem;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
extraRightPadding?: number;
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
extraRightPadding = 0,
}) => {
return (
<div
className={`${styles['tooltip-body']}`}
style={{
color: 'var(--text-primary)',
padding: '16px',
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
fontSize: '14px',
lineHeight: '1.6'
}}
@@ -1,8 +1,9 @@
import { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
import { useState, useEffect } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import apiClient from "@app/services/apiClient";
interface CompressSettingsProps {
parameters: CompressParameters;
@@ -13,6 +14,20 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation();
const [isSliding, setIsSliding] = useState(false);
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
useEffect(() => {
const checkImageMagick = async () => {
try {
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
setImageMagickAvailable(response.data);
} catch (error) {
console.error('Failed to check ImageMagick availability:', error);
setImageMagickAvailable(true); // Optimistic fallback
}
};
checkImageMagick();
}, []);
return (
<Stack gap="md">
@@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
disabled={disabled}
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
/>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable === false
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
}
/>
{parameters.lineArt && (
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
<Slider
min={1}
max={5}
step={1}
value={(() => {
// Map threshold to slider position
const thresholdMap = [20, 35, 50, 65, 80];
const closest = thresholdMap.reduce((prev, curr, idx) =>
Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold)
? idx : prev, 0);
return closest + 1;
})()}
onChange={(value) => {
// Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80%
const thresholdMap = [20, 35, 50, 65, 80];
onParameterChange('lineArtThreshold', thresholdMap[value - 1]);
}}
disabled={disabled || imageMagickAvailable === false}
label={null}
marks={[
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
{ value: 5 },
]}
/>
<Text size="sm" fw={600}>{t('compress.lineArt.edgeEmphasis', 'Edge emphasis')}</Text>
<SegmentedControl
fullWidth
disabled={disabled || imageMagickAvailable === false}
data={[
{ value: '1', label: t('compress.lineArt.edgeLow', 'Gentle') },
{ value: '2', label: t('compress.lineArt.edgeMedium', 'Balanced') },
{ value: '3', label: t('compress.lineArt.edgeHigh', 'Strong') },
]}
value={parameters.lineArtEdgeLevel.toString()}
onChange={(value) => onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)}
/>
</Stack>
)}
</Stack>
</Stack>
);
@@ -853,6 +853,12 @@ const SignSettings = ({
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
label={translate('text.name', 'Text')}
placeholder={translate('text.placeholder', 'Enter text')}
fontLabel={translate('text.fontLabel', 'Font')}
fontSizeLabel={translate('text.fontSizeLabel', 'Font size')}
fontSizePlaceholder={translate('text.fontSizePlaceholder', 'Type or select font size (8-200)')}
colorLabel={translate('text.colorLabel', 'Text colour')}
onAnyChange={() => {
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent, TooltipTip } from '@app/types/tips';
export const useAddPageNumbersTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("addPageNumbers.help.title", "Page Numbers & Bates Numbering Help")
},
tips: [
{
title: t("addPageNumbers.help.variables", "Variable Substitution"),
description: t("addPageNumbers.help.variablesDesc", "Use these variables in the Custom Text Format field to create dynamic page numbering:"),
bullets: [
t("addPageNumbers.help.variableN", "{n} - Current page number"),
t("addPageNumbers.help.variableTotal", "{total} - Total number of pages"),
t("addPageNumbers.help.variableFilename", "{filename} - Document filename (without extension)")
]
},
{
title: t("addPageNumbers.help.examples", "Common Examples"),
description: "",
bullets: [
t("addPageNumbers.help.exampleSimple", "Simple numbering: {n} → 1, 2, 3, 4..."),
t("addPageNumbers.help.examplePageOf", "Page X of Y: Page {n} of {total} → Page 1 of 10, Page 2 of 10..."),
t("addPageNumbers.help.exampleBates", "Legal Bates numbering: ABC-{n} → ABC-001, ABC-002, ABC-003..."),
t("addPageNumbers.help.exampleDocument", "Document reference: {filename}-{n} → mydoc-001, mydoc-002..."),
t("addPageNumbers.help.exampleCustom", "Custom format: Doc {filename} | Page {n}/{total}")
]
},
{
title: t("addPageNumbers.help.positioning", "Positioning"),
description: t("addPageNumbers.help.positioningDesc", "Use the 1-9 grid system to quickly position page numbers, or use the margin size to adjust distance from edges.")
},
{
title: t("addPageNumbers.help.tips", "Tips"),
description: "",
bullets: [
t("addPageNumbers.help.tip1", "Starting Number: Change the starting number to begin counting from any value (useful for multi-part documents)"),
t("addPageNumbers.help.tip2", "Page Selection: Number only specific pages by entering ranges like 1,3,5-8"),
t("addPageNumbers.help.tip3", "Formatting: Choose font type, size, and color to match your document style")
]
}
]
};
};
@@ -0,0 +1,85 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useAddStampSetupTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("AddStampRequest.help.title", "Stamp Tool Help")
},
tips: [
{
title: t("AddStampRequest.help.overview", "Overview"),
description: t("AddStampRequest.help.overview", "Add text or image stamps to PDFs at precise locations. Supports variable substitution and multi-language fonts.")
},
{
title: t("AddStampRequest.help.stampTypes", "Stamp Types"),
description: "",
bullets: [
t("AddStampRequest.help.textStamp", "Text: Quick approval stamps ('APPROVED', 'REVIEWED', 'CONFIDENTIAL'), custom messages"),
t("AddStampRequest.help.imageStamp", "Image: Upload logos, signatures, or custom graphics")
]
},
{
title: t("AddStampRequest.help.variables", "Variable Substitution (Text Stamps)"),
description: t("AddStampRequest.help.variablesDesc", "Use these variables in your stamp text for dynamic content:"),
bullets: [
t("AddStampRequest.help.variableN", "{n} - Current page number"),
t("AddStampRequest.help.variableTotal", "{total} - Total pages"),
t("AddStampRequest.help.variableFilename", "{filename} - Document filename"),
t("AddStampRequest.help.variableExample", "Example: 'Document {filename} - Page {n}' becomes 'Document myfile - Page 5'")
]
},
{
title: t("AddStampRequest.help.multiLanguage", "Multi-Language Support"),
description: t("AddStampRequest.help.multiLanguageDesc", "Choose alphabet/language for proper font rendering: Roman, Arabic, Japanese, Korean, Chinese, Thai. Essential for non-Latin text.")
},
{
title: t("AddStampRequest.help.useCases", "Common Use Cases"),
description: "",
bullets: [
t("AddStampRequest.help.useCase1", "Approval stamps: 'APPROVED', 'REVIEWED BY [NAME]', 'CONFIDENTIAL'"),
t("AddStampRequest.help.useCase2", "Business stamps: Company logos, official seals"),
t("AddStampRequest.help.useCase3", "Page references: 'Page {n} of {total}'"),
t("AddStampRequest.help.useCase4", "Document identifiers: '{filename} - {n}'")
]
}
]
};
};
export const useAddStampPositionTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("AddStampRequest.help.positioningTitle", "Positioning & Formatting")
},
tips: [
{
title: t("AddStampRequest.help.positioning", "Positioning System"),
description: "",
bullets: [
t("AddStampRequest.help.positionGrid", "Quick Position: Use 1-9 grid (1=top-left, 5=center, 9=bottom-right) for fast placement"),
t("AddStampRequest.help.positionOverride", "Override Coordinates: Enter exact X/Y pixel coordinates for precise placement (overrides grid position)"),
t("AddStampRequest.help.positionMargin", "Custom Margin: Adjust distance from page edges (Small/Medium/Large/X-Large)")
]
},
{
title: t("AddStampRequest.help.formatting", "Formatting Options"),
description: "",
bullets: [
t("AddStampRequest.help.rotation", "Rotation: -360° to 360° for angled stamps"),
t("AddStampRequest.help.opacity", "Opacity: 0-100% for transparency (lower = more transparent)"),
t("AddStampRequest.help.color", "Custom Colour: Choose any colour for text stamps"),
t("AddStampRequest.help.fontSize", "Font/Image Size: Adjust size of text or image stamps")
]
},
{
title: t("AddStampRequest.help.previewTip", "Preview Tip"),
description: t("AddStampRequest.help.previewTipDesc", "Use the preview to see how your stamp will look before applying. For image stamps, you can drag to position or use the quick grid.")
}
]
};
};
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useAutoSplitPDFTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("autoSplitPDF.help.title", "QR Code Auto-Split Help")
},
tips: [
{
title: t("autoSplitPDF.help.overview", "Overview"),
description: t("autoSplitPDF.help.overview", "This tool automatically splits scanned PDFs using special QR code divider pages. Perfect for batch scanning multiple documents.")
},
{
title: t("autoSplitPDF.help.howItWorks", "How It Works"),
description: "",
bullets: [
t("autoSplitPDF.help.step1", "1. Download and print the QR code divider page (black & white is fine)"),
t("autoSplitPDF.help.step2", "2. Place divider pages between your documents"),
t("autoSplitPDF.help.step3", "3. Scan all documents in one batch (dividers included)"),
t("autoSplitPDF.help.step4", "4. Upload to Stirling-PDF - documents are automatically separated and dividers removed")
]
},
{
title: t("autoSplitPDF.help.duplexMode", "Duplex Mode (Double-Sided Scanning)"),
description: t("autoSplitPDF.help.duplexDesc", "Enable 'Duplex Mode' when scanning double-sided documents. This automatically skips the back sides of divider pages, preventing blank pages in your output."),
bullets: [
t("autoSplitPDF.help.duplexExample", "Example: With duplex ON, if you scan a divider followed by a 2-page document, the tool knows the divider's back is blank and skips it.")
]
},
{
title: t("autoSplitPDF.help.qrCodes", "Valid QR Codes"),
description: t("autoSplitPDF.help.qrDesc", "Only QR codes from Stirling-PDF divider pages work. These contain specific URLs: github.com/Stirling-Tools/Stirling-PDF, github.com/Frooodle/Stirling-PDF, or stirlingpdf.com")
},
{
title: t("autoSplitPDF.help.useCases", "Best Use Cases"),
description: "",
bullets: [
t("autoSplitPDF.help.useCase1", "Batch scanning multiple contracts, forms, or reports"),
t("autoSplitPDF.help.useCase2", "Digitizing physical file folders"),
t("autoSplitPDF.help.useCase3", "Scanning stacks of invoices or receipts"),
t("autoSplitPDF.help.useCase4", "Processing mail or paperwork in bulk")
]
},
{
title: t("autoSplitPDF.help.tips", "Tips"),
description: "",
bullets: [
t("autoSplitPDF.help.tip1", "Print dividers once, reuse them for all your scanning sessions"),
t("autoSplitPDF.help.tip2", "Dividers work in black & white - no need for color printing"),
t("autoSplitPDF.help.tip3", "Make sure QR codes are clearly visible (not crumpled or dirty)"),
t("autoSplitPDF.help.tip4", "For best results, use the same paper weight for dividers as your documents")
]
}
]
};
};
@@ -6,24 +6,56 @@ export const useCompressTips = (): TooltipContent => {
return {
header: {
title: t("compress.tooltip.header.title", "Compress Settings Overview")
title: t("compress.help.title", "PDF Compression Guide")
},
tips: [
{
title: t("compress.tooltip.description.title", "Description"),
description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.")
title: t("compress.help.overview", "Overview"),
description: t("compress.help.overview", "Reduce PDF file size while balancing quality. Uses qpdf for compression and optimization.")
},
{
title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"),
description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."),
title: t("compress.help.methods", "Compression Methods"),
description: "",
bullets: [
t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"),
t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size")
t("compress.help.qualityMethod", "Quality: Choose compression strength (1-9). Lower preserves quality, higher reduces size more aggressively. Recommended: 3-5 for most documents."),
t("compress.help.filesizeMethod", "File Size: Enter target size - tool automatically adjusts quality to reach it. Best when you have size limits (email attachments, etc.).")
]
},
{
title: t("compress.tooltip.grayscale.title", "Grayscale"),
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
title: t("compress.help.options", "Additional Options"),
description: "",
bullets: [
t("compress.help.grayscale", "Grayscale: Converts all colors to black & white. Dramatically reduces size for color-heavy documents. Best for: Text documents, reports, forms."),
t("compress.help.lineArt", "Line Art: Maximum compression. Converts to high-contrast black & white (requires ImageMagick). Best for: Text-only documents, technical drawings. NOT recommended for photos.")
]
},
{
title: t("compress.help.qualityLevels", "Quality Level Guide"),
description: "",
bullets: [
t("compress.help.level1to3", "1-3: High quality. Minimal compression. Use for important documents, presentations, or photos."),
t("compress.help.level4to6", "4-6: Balanced. Noticeable compression with acceptable quality. Good for general use."),
t("compress.help.level7to9", "7-9: Maximum compression. Lower quality but smallest files. Use for drafts, internal documents.")
]
},
{
title: t("compress.help.tips", "Tips"),
description: "",
bullets: [
t("compress.help.tip1", "Start with quality level 3-4 and increase if more compression needed"),
t("compress.help.tip2", "Grayscale option works well for scanned documents without photos"),
t("compress.help.tip3", "Line art is extreme - preview results before using on final documents"),
t("compress.help.tip4", "File size method is convenient but may produce varying quality across pages")
]
},
{
title: t("compress.help.expectations", "Size Reduction Expectations"),
description: "",
bullets: [
t("compress.help.typical", "Typical: 20-40% reduction for standard PDFs"),
t("compress.help.images", "Image-heavy: 50-70% reduction with grayscale"),
t("compress.help.extreme", "Line art: 80-95% reduction (text becomes images)")
]
}
]
};
@@ -6,29 +6,56 @@ export const useFlattenTips = (): TooltipContent => {
return {
header: {
title: t("flatten.tooltip.header.title", "About Flattening PDFs")
title: t("flatten.help.title", "Flatten PDF Guide")
},
tips: [
{
title: t("flatten.tooltip.description.title", "What does flattening do?"),
description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."),
title: t("flatten.help.overview", "Overview"),
description: t("flatten.help.overview", "Flattening converts interactive PDF elements into static content. Makes forms non-editable and removes interactivity.")
},
{
title: t("flatten.help.whatGetsFlattened", "What Gets Flattened"),
description: "",
bullets: [
t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"),
t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"),
t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"),
t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices")
t("flatten.help.fullFlatten", "Full Flatten (default): Text fields, checkboxes, radio buttons, dropdowns, buttons, annotations, and all interactive elements become static images/text."),
t("flatten.help.formsOnly", "Forms Only: Only form fields become static. Links, bookmarks, comments, and annotations remain interactive.")
]
},
{
title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"),
description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."),
title: t("flatten.help.whenToFlatten", "When to Flatten"),
description: "",
bullets: [
t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"),
t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"),
t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"),
t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate")
t("flatten.help.useCase1", "Completed forms: After filling out a form, flatten to prevent further changes"),
t("flatten.help.useCase2", "Final documents: Create locked versions for record-keeping or distribution"),
t("flatten.help.useCase3", "Watermarked docs: After adding watermarks, flatten to prevent removal"),
t("flatten.help.useCase4", "Pre-printed forms: Convert fillable PDFs to printable forms with visible fields"),
t("flatten.help.useCase5", "Consistency: Ensure PDF looks identical across all viewers and devices")
]
},
{
title: t("flatten.help.whatStaysInteractive", "What Stays Interactive (Forms Only mode)"),
description: "",
bullets: [
t("flatten.help.interactive1", "Hyperlinks and web links remain clickable"),
t("flatten.help.interactive2", "Bookmarks/table of contents for navigation"),
t("flatten.help.interactive3", "Comments and annotations remain visible and editable"),
t("flatten.help.interactive4", "Document outline and layers")
]
},
{
title: t("flatten.help.important", "Important Notes"),
description: "",
bullets: [
t("flatten.help.note1", "Flattening is permanent - cannot be reversed. Keep original if you need to edit later."),
t("flatten.help.note2", "File size may increase slightly as form elements become images"),
t("flatten.help.note3", "Flattened forms cannot be un-flattened - the form data is permanently merged"),
t("flatten.help.note4", "Digital signatures may be invalidated by flattening")
]
},
{
title: t("flatten.help.alternatives", "Alternative to Flattening"),
description: t("flatten.help.altPermissions", "If you only want to prevent editing, consider using 'Change Permissions' instead of flattening. This keeps the PDF interactive but locked.")
}
]
};
};
};

Some files were not shown because too many files have changed in this diff Show More