Compare commits

...
21 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
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 71d416ce90 Merge branch 'main' into pdfCache 2025-12-15 15:35:40 +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
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
38 changed files with 4319 additions and 2902 deletions
+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
@@ -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;
@@ -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;
}
}
}
@@ -52,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")
@@ -168,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");
}
@@ -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(
@@ -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());
@@ -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;
}
}
@@ -74,13 +74,13 @@ public class ReactRoutingController {
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|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|pdfium|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);
@@ -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"
}
@@ -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;
@@ -344,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.");
@@ -102,16 +102,31 @@ public class Saml2Configuration {
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("{baseUrl}/login")
.singleLogoutServiceResponseLocation(sloResponseLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
@@ -127,7 +142,7 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
"{baseUrl}/login")
sloResponseLocation)
.wantAuthnRequestsSigned(true))
.build();
@@ -51,7 +51,8 @@ class UserLicenseSettingsServiceTest {
when(applicationProperties.getPremium()).thenReturn(premium);
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
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)))
+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",
+390 -124
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)"
@@ -657,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"
@@ -682,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"
@@ -842,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"
@@ -857,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"
@@ -872,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"
@@ -882,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"
@@ -907,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"
@@ -929,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"
@@ -942,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"
@@ -1562,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"
@@ -2231,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"
@@ -2404,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"
@@ -2738,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"
@@ -2862,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."
@@ -3013,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"
@@ -3282,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"
@@ -3401,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"
@@ -3765,6 +3967,29 @@ text = "Convert pages to high-contrast black and white using ImageMagick. Use de
[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"
@@ -4805,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"
@@ -6254,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
+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,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}
/>
</>
);
};
@@ -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>
);
};
@@ -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>
);
}
@@ -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,31 +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.tooltip.lineArt.title", "Line Art"),
description: t(
"compress.tooltip.lineArt.text",
"Convert pages to high-contrast black and white using ImageMagick. Use line thickness to control the threshold percentage and detection strength to choose how aggressively edges are outlined."
)
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.")
}
]
};
};
};
@@ -6,16 +6,16 @@ export const useRedactModeTips = (): TooltipContent => {
return {
header: {
title: t("redact.tooltip.mode.header.title", "Redaction Method")
title: t("redact.help.modeTitle", "Redaction Method")
},
tips: [
{
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
title: t("redact.help.overview", "Overview"),
description: t("redact.help.overviewDesc", "Redaction permanently removes sensitive content from PDFs. Text behind redaction boxes is completely removed, not just covered. Metadata is also cleaned automatically.")
},
{
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
title: t("redact.help.automaticMode", "Automatic Redaction"),
description: t("redact.help.automaticDesc", "Search for specific words, phrases, or patterns throughout the document and automatically redact all matches. Supports text search and regular expressions.")
}
]
};
@@ -26,21 +26,38 @@ export const useRedactWordsTips = (): TooltipContent => {
return {
header: {
title: t("redact.tooltip.words.header.title", "Words to Redact")
title: t("redact.help.wordsTitle", "Words & Patterns to Redact")
},
tips: [
{
title: t("redact.tooltip.words.description.title", "Text Matching"),
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
title: t("redact.help.enterWords", "Enter Text to Redact"),
description: t("redact.help.enterWordsDesc", "Type the words or phrases you want to remove from the document. Each word/phrase will be searched and redacted automatically."),
bullets: [
t("redact.tooltip.words.bullet1", "Add one word at a time"),
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
t("redact.tooltip.words.bullet3", "Click × to remove words")
t("redact.help.enterWordsBullet1", "Enter one or more words separated by commas"),
t("redact.help.enterWordsBullet2", "Case sensitive by default"),
t("redact.help.enterWordsBullet3", "Use whole word search to avoid partial matches")
]
},
{
title: t("redact.tooltip.words.examples.title", "Common Examples"),
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
title: t("redact.help.wholeWordMode", "Whole Word Search"),
description: t("redact.help.wholeWordDesc", "Enable 'Whole Word Search' to match only complete words. Example: 'John' won't match 'Johnson' when enabled. Useful for names.")
},
{
title: t("redact.help.regexMode", "Regular Expression (Regex) Mode"),
description: t("redact.help.regexDesc", "Enable 'Use Regex' to use pattern-based matching for complex searches. Powerful for finding multiple instances of similar data.")
},
{
title: t("redact.help.regexExamples", "Common Regex Patterns"),
description: "",
bullets: [
t("redact.help.regexSSN", "Social Security Number: \\d{3}-\\d{2}-\\d{4} (matches XXX-XX-XXXX)"),
t("redact.help.regexPhone", "Phone Number: \\(?\\d{3}\\)?[-.]?\\d{3}[-.]?\\d{4} (matches various phone formats)"),
t("redact.help.regexEmail", "Email Address: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,} (matches email addresses)"),
t("redact.help.regexCreditCard", "Credit Card: \\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4} (matches card numbers)"),
t("redact.help.regexDate", "Date (YYYY-MM-DD): \\d{4}-\\d{2}-\\d{2}"),
t("redact.help.regexZipCode", "US ZIP Code: \\d{5}(-\\d{4})? (matches 12345 or 12345-6789)"),
t("redact.help.regexIPAddress", "IP Address: \\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")
]
}
]
};
@@ -51,28 +68,34 @@ export const useRedactAdvancedTips = (): TooltipContent => {
return {
header: {
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
title: t("redact.help.advancedTitle", "Advanced Settings")
},
tips: [
{
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
title: t("redact.help.customColor", "Custom Redaction Color"),
description: t("redact.help.customColorDesc", "Choose the color of the redaction boxes. Default is black for maximum privacy.")
},
{
title: t("redact.tooltip.advanced.regex.title", "Use Regex"),
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
title: t("redact.help.padding", "Custom Padding"),
description: t("redact.help.paddingDesc", "Add extra space around redaction boxes to ensure complete coverage. Useful when fonts vary in size or style.")
},
{
title: t("redact.help.securityFeatures", "Security Features"),
description: "",
bullets: [
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
t("redact.help.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."),
t("redact.help.securityMetadata", "Automatic Metadata Cleaning: Automatically removes author, subject, keywords, and XMP metadata for privacy.")
]
},
{
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
},
{
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
title: t("redact.help.tips", "Best Practices"),
description: "",
bullets: [
t("redact.help.tip1", "Test First: Test your regex patterns on a copy before using on important documents."),
t("redact.help.tip2", "Review Results: Always review redacted documents to ensure all sensitive information is removed."),
t("redact.help.tip3", "Use Convert to Image: For highly sensitive documents, enable 'Convert to PDF-Image' to ensure complete removal."),
t("redact.help.tip4", "Padding: Add custom padding to ensure redaction boxes fully cover text (useful for different font sizes).")
]
}
]
};
@@ -6,49 +6,43 @@ export const useScannerImageSplitTips = (): TooltipContent => {
return {
header: {
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
title: t("scannerImageSplit.help.title", "Advanced OpenCV Parameters")
},
tips: [
{
title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'),
description: t('scannerImageSplit.tooltip.whatThisDoesDesc',
'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.'
)
title: t("scannerImageSplit.help.overview", "Overview"),
description: t("scannerImageSplit.help.overview", "This tool uses OpenCV (computer vision library) to automatically detect individual photos on scanned pages.")
},
{
title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'),
title: t("scannerImageSplit.help.angleThreshold", "Angle Threshold (Default: 5)"),
description: t("scannerImageSplit.help.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.")
},
{
title: t("scannerImageSplit.help.tolerance", "Tolerance (Default: 20)"),
description: t("scannerImageSplit.help.toleranceDesc", "How closely a color 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.")
},
{
title: t("scannerImageSplit.help.minArea", "Minimum Area (Default: 8000)"),
description: t("scannerImageSplit.help.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.")
},
{
title: t("scannerImageSplit.help.minContourArea", "Minimum Contour Area (Default: 500)"),
description: t("scannerImageSplit.help.minContourAreaDesc", "Smallest edge/shape size when detecting photo boundaries. Increase to 1000-2000 to filter out dust and specks. Lower values detect finer edges.")
},
{
title: t("scannerImageSplit.help.borderSize", "Border Size (Default: 1)"),
description: t("scannerImageSplit.help.borderSizeDesc", "Extra padding in pixels around each extracted photo. Increase to 5-10 to avoid cutting edges. Set to 0 for no padding.")
},
{
title: t("scannerImageSplit.help.recommendedSettings", "Recommended Settings"),
description: "",
bullets: [
t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'),
t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'),
t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'),
t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents')
t("scannerImageSplit.help.normalScans", "Normal photo scans: Use defaults (Angle: 5, Tolerance: 20, Min Area: 8000)"),
t("scannerImageSplit.help.highQuality", "High-quality photos on clean background: Tolerance 15, Min Area 10000, Border 3"),
t("scannerImageSplit.help.noisyScans", "Noisy/dirty scans: Tolerance 30, Min Contour Area 1500, Border 5"),
t("scannerImageSplit.help.smallPhotos", "Small photos (ID cards, stamps): Min Area 3000, Border 2")
]
},
{
title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'),
bullets: [
t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 3050'),
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,00020,000'),
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 510'),
t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'),
t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 10002000')
]
},
{
title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'),
bullets: [
t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'),
t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'),
t('scannerImageSplit.tooltip.tip3', 'Scan at 300600 DPI'),
t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass')
]
},
{
title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'),
description: t('scannerImageSplit.tooltip.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.'
)
}
]
};
};
};
+36
View File
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
interface AppConfig {
frontendUrl?: string;
[key: string]: unknown;
}
/**
* Hook to get the configured frontend URL from backend app-config.
* Falls back to window.location.origin if not configured.
*/
export const useFrontendUrl = (): string => {
const [frontendUrl, setFrontendUrl] = useState<string>(window.location.origin);
useEffect(() => {
const fetchFrontendUrl = async () => {
try {
const response = await apiClient.get<AppConfig>('/api/v1/app-config');
const configuredUrl = response.data.frontendUrl;
// Use configured URL if not empty, otherwise keep window.location.origin
if (configuredUrl && configuredUrl.trim() !== '') {
setFrontendUrl(configuredUrl);
}
} catch (error) {
console.warn('Failed to fetch app config, using window.location.origin:', error);
// Keep the default window.location.origin on error
}
};
fetchFrontendUrl();
}, []);
return frontendUrl;
};
@@ -0,0 +1,897 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Box, Button, Stack, Text, Group, Alert, Tabs, Progress, Switch, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import PhotoCameraRoundedIcon from '@mui/icons-material/PhotoCameraRounded';
import UploadRoundedIcon from '@mui/icons-material/UploadRounded';
import AddPhotoAlternateRoundedIcon from '@mui/icons-material/AddPhotoAlternateRounded';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
// jscanify is loaded via script tag in index.html as a global
declare global {
interface Window {
jscanify: any;
cv: any;
}
}
/**
* MobileScannerPage
*
* Mobile-friendly page for capturing photos and uploading them to the backend server.
* Accessed by scanning QR code from desktop.
*/
export default function MobileScannerPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const sessionId = searchParams.get('session');
const { colorScheme } = useMantineColorScheme();
const brandIconSrc = useLogoPath();
const { wordmark } = useLogoAssets();
const brandTextSrc = colorScheme === 'dark' ? wordmark.white : wordmark.black;
const [activeTab, setActiveTab] = useState<string | null>('camera');
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [currentPreview, setCurrentPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const [autoEnhance, setAutoEnhance] = useState(true);
const [showLiveDetection, setShowLiveDetection] = useState(true); // On by default with adaptive performance
const [isProcessing, setIsProcessing] = useState(false);
const [openCvReady, setOpenCvReady] = useState(false);
const [torchEnabled, setTorchEnabled] = useState(false);
const [torchSupported, setTorchSupported] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const highlightCanvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scannerRef = useRef<any>(null);
const highlightIntervalRef = useRef<number | null>(null);
// Detection resolution - extremely low for mobile performance
const DETECTION_WIDTH = 160; // Ultra-low for real-time mobile detection
// Initialize jscanify scanner and wait for OpenCV (loaded via script tags in index.html)
useEffect(() => {
let retryCount = 0;
const MAX_RETRIES = 50; // 5 seconds max wait
const initScanner = () => {
// Check if both OpenCV and jscanify are loaded
if (!(window as any).cv || !(window as any).cv.Mat) {
retryCount++;
if (retryCount < MAX_RETRIES) {
if (retryCount % 10 === 1) {
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for OpenCV to load...`);
}
setTimeout(initScanner, 100);
} else {
console.error('OpenCV failed to load after 5 seconds. Check that /vendor/jscanify/opencv.js is accessible.');
}
return;
}
if (!window.jscanify) {
retryCount++;
if (retryCount < MAX_RETRIES) {
if (retryCount % 10 === 1) {
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for jscanify to load...`);
}
setTimeout(initScanner, 100);
} else {
console.error('jscanify failed to load after 5 seconds. Check that /vendor/jscanify/jscanify.js is accessible.');
}
return;
}
try {
scannerRef.current = new window.jscanify();
setOpenCvReady(true);
console.log('✓ jscanify initialized with OpenCV');
} catch (err) {
console.error('Failed to initialize jscanify:', err);
}
};
// Start initialization
initScanner();
}, []);
// Initialize camera
useEffect(() => {
if (activeTab === 'camera' && !cameraError && !currentPreview) {
// Check if mediaDevices API is available (requires HTTPS or localhost)
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('MediaDevices API not available - requires HTTPS or localhost');
setCameraError(
t(
'mobileScanner.httpsRequired',
'Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost.'
)
);
setActiveTab('file');
return;
}
navigator.mediaDevices
.getUserMedia({
video: {
facingMode: 'environment',
// Request 1080p - good quality without going overboard
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
},
audio: false,
})
.then(async (stream) => {
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
// Log actual resolution we got
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack.getSettings();
console.log('Camera resolution:', settings.width, 'x', settings.height);
// Configure camera capabilities for document scanning
try {
const capabilities = videoTrack.getCapabilities() as any; // Cast to any for experimental camera APIs
const constraints: any = { advanced: [] };
// 1. Enable continuous autofocus
if (capabilities.focusMode && capabilities.focusMode.includes('continuous')) {
constraints.advanced.push({ focusMode: 'continuous' });
console.log('✓ Continuous autofocus enabled');
}
// 2. Enable continuous auto-exposure for varying lighting
if (capabilities.exposureMode && capabilities.exposureMode.includes('continuous')) {
constraints.advanced.push({ exposureMode: 'continuous' });
console.log('✓ Auto-exposure enabled');
}
// 3. Check if torch/flashlight is supported
if (capabilities.torch) {
setTorchSupported(true);
console.log('✓ Torch/flashlight available');
}
// Apply all constraints
if (constraints.advanced.length > 0) {
await videoTrack.applyConstraints(constraints);
}
} catch (err) {
console.log('Could not configure camera features:', err);
}
}
})
.catch((err) => {
console.error('Camera error:', err);
setCameraError(t('mobileScanner.cameraAccessDenied', 'Camera access denied. Please enable camera access.'));
// Auto-switch to file upload if camera fails
setActiveTab('file');
});
}
return () => {
// Clean up stream when switching away from camera or showing preview
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
// Stop highlighting when camera is stopped
if (highlightIntervalRef.current) {
clearInterval(highlightIntervalRef.current);
highlightIntervalRef.current = null;
}
};
}, [activeTab, cameraError, currentPreview, t]);
// Real-time document highlighting on camera feed
useEffect(() => {
console.log(`[Mobile Scanner] Effect triggered: activeTab=${activeTab}, showLiveDetection=${showLiveDetection}, openCvReady=${openCvReady}, currentPreview=${currentPreview}`);
if (activeTab === 'camera' && showLiveDetection && openCvReady && scannerRef.current && !currentPreview) {
const startHighlighting = () => {
if (!videoRef.current || !highlightCanvasRef.current) return;
if (!videoRef.current.videoWidth || !videoRef.current.videoHeight) return;
const video = videoRef.current;
const highlightCanvas = highlightCanvasRef.current;
// Create low-res detection canvas with optimized context for frequent pixel reading
const detectionCanvas = document.createElement('canvas');
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
if (!detectionCtx) return;
// Calculate scaled dimensions for detection (160px wide max)
const scale = DETECTION_WIDTH / video.videoWidth;
detectionCanvas.width = DETECTION_WIDTH;
detectionCanvas.height = Math.round(video.videoHeight * scale);
// CRITICAL FIX: Make highlight canvas ALSO low-res (CSS will scale it visually)
// Drawing to a 4K canvas is what was causing the lag!
highlightCanvas.width = DETECTION_WIDTH;
highlightCanvas.height = Math.round(video.videoHeight * scale);
console.log(`[Mobile Scanner] Video: ${video.videoWidth}x${video.videoHeight}`);
console.log(`[Mobile Scanner] Detection: ${detectionCanvas.width}x${detectionCanvas.height} (${Math.round(scale * 100)}%)`);
console.log(`[Mobile Scanner] Highlight canvas: ${highlightCanvas.width}x${highlightCanvas.height}`);
console.log(`[Mobile Scanner] Starting interval at 1 FPS`);
// Set highlight canvas to match video for vector drawing
highlightCanvas.width = video.videoWidth;
highlightCanvas.height = video.videoHeight;
const highlightCtx = highlightCanvas.getContext('2d', { willReadFrequently: true });
if (!highlightCtx) return;
// Use requestAnimationFrame with adaptive throttle based on device performance
let frameCount = 0;
const frameTimes: number[] = [];
let lastDetectionTime = 0;
let detectionInterval = 333; // Start at 3 FPS (333ms)
const detectionTimings: number[] = []; // Track last 10 detection times
const MAX_TIMINGS = 10;
const runDetection = () => {
const now = performance.now();
// Only run detection every second
if (now - lastDetectionTime >= detectionInterval) {
lastDetectionTime = now;
const startTime = performance.now();
try {
// Step 1: Copy video to low-res detection canvas
const copyStart = performance.now();
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
const copyTime = performance.now() - copyStart;
// Step 2: Run detection on low-res to get corner points
const detectionStart = performance.now();
const mat = (window as any).cv.imread(detectionCanvas);
const contour = scannerRef.current.findPaperContour(mat);
let corners = null;
if (contour) {
// Validate contour area (reject if too small or too large)
const contourArea = (window as any).cv.contourArea(contour);
const frameArea = detectionCanvas.width * detectionCanvas.height;
const areaPercent = (contourArea / frameArea) * 100;
// Only accept if contour is 15-85% of frame (filters out noise and frame edges)
if (areaPercent >= 15 && areaPercent <= 85) {
corners = scannerRef.current.getCornerPoints(contour);
}
}
mat.delete();
const detectionTime = performance.now() - detectionStart;
// Step 3: Draw ONLY the corner lines on full-res canvas (super fast!)
const drawStart = performance.now();
highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height);
// Validate we have all 4 corners (no triangles!)
if (
corners &&
corners.topLeftCorner &&
corners.topRightCorner &&
corners.bottomLeftCorner &&
corners.bottomRightCorner
) {
// Scale corner points from low-res to full-res
const scaleFactor = video.videoWidth / detectionCanvas.width;
const tl = { x: corners.topLeftCorner.x * scaleFactor, y: corners.topLeftCorner.y * scaleFactor };
const tr = { x: corners.topRightCorner.x * scaleFactor, y: corners.topRightCorner.y * scaleFactor };
const br = { x: corners.bottomRightCorner.x * scaleFactor, y: corners.bottomRightCorner.y * scaleFactor };
const bl = { x: corners.bottomLeftCorner.x * scaleFactor, y: corners.bottomLeftCorner.y * scaleFactor };
// Validation 1: Minimum distance between corners
const minDistance = 50;
const distances = [
Math.hypot(tr.x - tl.x, tr.y - tl.y),
Math.hypot(br.x - tr.x, br.y - tr.y),
Math.hypot(bl.x - br.x, bl.y - br.y),
Math.hypot(tl.x - bl.x, tl.y - bl.y),
];
const allCornersSpaced = distances.every((d) => d > minDistance);
// Validation 2: Aspect ratio (documents are ~1.0 to 1.5 ratio)
const width1 = Math.hypot(tr.x - tl.x, tr.y - tl.y);
const width2 = Math.hypot(br.x - bl.x, br.y - bl.y);
const height1 = Math.hypot(bl.x - tl.x, bl.y - tl.y);
const height2 = Math.hypot(br.x - tr.x, br.y - tr.y);
const avgWidth = (width1 + width2) / 2;
const avgHeight = (height1 + height2) / 2;
const aspectRatio = Math.max(avgWidth, avgHeight) / Math.min(avgWidth, avgHeight);
// Accept aspect ratios from 1:1 (square) to 1:2 (elongated document)
const goodAspectRatio = aspectRatio >= 1.0 && aspectRatio <= 2.0;
if (allCornersSpaced && goodAspectRatio) {
// Draw lines connecting corners (vector graphics - super lightweight!)
highlightCtx.strokeStyle = '#00FF00';
highlightCtx.lineWidth = 4;
highlightCtx.beginPath();
highlightCtx.moveTo(tl.x, tl.y);
highlightCtx.lineTo(tr.x, tr.y);
highlightCtx.lineTo(br.x, br.y);
highlightCtx.lineTo(bl.x, bl.y);
highlightCtx.lineTo(tl.x, tl.y);
highlightCtx.stroke();
}
}
const drawTime = performance.now() - drawStart;
const totalTime = performance.now() - startTime;
frameCount++;
frameTimes.push(totalTime);
// Track detection timings for adaptive performance
detectionTimings.push(totalTime);
if (detectionTimings.length > MAX_TIMINGS) {
detectionTimings.shift(); // Keep only last 10
}
// Adaptive performance adjustment (after warmup period)
if (frameCount > 5 && detectionTimings.length >= 5) {
const avgTime = detectionTimings.reduce((a, b) => a + b, 0) / detectionTimings.length;
// Adjust detection interval based on average performance
if (avgTime < 20) {
// Very fast device: 5 FPS (200ms)
detectionInterval = 200;
} else if (avgTime < 40) {
// Fast device: 3 FPS (333ms)
detectionInterval = 333;
} else if (avgTime < 80) {
// Medium device: 2 FPS (500ms)
detectionInterval = 500;
} else {
// Slower device: 1 FPS (1000ms)
detectionInterval = 1000;
}
}
if (frameCount <= 10) {
console.log(`[Mobile Scanner] Frame ${frameCount}: ${Math.round(totalTime)}ms total (copy: ${Math.round(copyTime)}ms, detect: ${Math.round(detectionTime)}ms, draw: ${Math.round(drawTime)}ms) - interval: ${detectionInterval}ms`);
}
if (frameCount === 10) {
const avg = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
console.log(`[Mobile Scanner] Average of first 10 frames: ${Math.round(avg)}ms - Adaptive rate: ${Math.round(1000/detectionInterval)} FPS`);
}
} catch (err) {
console.error('[Mobile Scanner] Detection error:', err);
}
}
// Continue animation loop
highlightIntervalRef.current = requestAnimationFrame(runDetection);
};
// Start the animation loop
highlightIntervalRef.current = requestAnimationFrame(runDetection);
};
// Wait for video to be ready
if (videoRef.current && videoRef.current.readyState >= 2) {
startHighlighting();
} else if (videoRef.current) {
videoRef.current.addEventListener('loadedmetadata', startHighlighting);
}
return () => {
if (highlightIntervalRef.current) {
console.log('[Mobile Scanner] Stopping detection');
cancelAnimationFrame(highlightIntervalRef.current);
highlightIntervalRef.current = null;
}
};
}
}, [activeTab, showLiveDetection, openCvReady, currentPreview]);
const captureImage = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
setIsProcessing(true);
try {
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
// Capture raw image from video at full resolution
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
let finalDataUrl: string;
// Apply jscanify processing if enabled and available
if (autoEnhance && scannerRef.current && openCvReady) {
try {
// Create low-res canvas for detection (faster processing)
const detectionCanvas = document.createElement('canvas');
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
if (!detectionCtx) throw new Error('Cannot create detection context');
const scale = DETECTION_WIDTH / video.videoWidth;
detectionCanvas.width = DETECTION_WIDTH;
detectionCanvas.height = Math.round(video.videoHeight * scale);
// Draw downscaled image for detection
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
// Run detection on low-res image
const mat = (window as any).cv.imread(detectionCanvas);
const contour = scannerRef.current.findPaperContour(mat);
if (contour) {
const cornerPoints = scannerRef.current.getCornerPoints(contour);
// Scale corner points back to full resolution
if (cornerPoints) {
const scaleFactor = 1 / scale;
const scaledCorners = {
topLeftCorner: { x: cornerPoints.topLeftCorner.x * scaleFactor, y: cornerPoints.topLeftCorner.y * scaleFactor },
topRightCorner: { x: cornerPoints.topRightCorner.x * scaleFactor, y: cornerPoints.topRightCorner.y * scaleFactor },
bottomLeftCorner: { x: cornerPoints.bottomLeftCorner.x * scaleFactor, y: cornerPoints.bottomLeftCorner.y * scaleFactor },
bottomRightCorner: { x: cornerPoints.bottomRightCorner.x * scaleFactor, y: cornerPoints.bottomRightCorner.y * scaleFactor },
};
// Use scaled corners for validation and extraction
const { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } = scaledCorners;
// Validate corner points are reasonable (minimum distance in full-res)
const minDistance = 100; // Minimum pixels between corners at full resolution
const distances = [
Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y),
Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y),
Math.hypot(bottomLeftCorner.x - bottomRightCorner.x, bottomLeftCorner.y - bottomRightCorner.y),
Math.hypot(topLeftCorner.x - bottomLeftCorner.x, topLeftCorner.y - bottomLeftCorner.y),
];
const isValidDetection = distances.every((d) => d > minDistance);
if (!isValidDetection) {
console.warn('Detected corners are too close together, using original image');
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
return;
}
console.log('Valid document detected at full resolution:', {
corners: scaledCorners,
distances: distances.map((d) => Math.round(d)),
});
// Calculate width and height of the document
const topWidth = Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y);
const bottomWidth = Math.hypot(bottomRightCorner.x - bottomLeftCorner.x, bottomRightCorner.y - bottomLeftCorner.y);
const leftHeight = Math.hypot(bottomLeftCorner.x - topLeftCorner.x, bottomLeftCorner.y - topLeftCorner.y);
const rightHeight = Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y);
// Use average dimensions to maintain proper aspect ratio
const docWidth = Math.round((topWidth + bottomWidth) / 2);
const docHeight = Math.round((leftHeight + rightHeight) / 2);
// Extract paper from full-resolution canvas with scaled corner points
const resultCanvas = scannerRef.current.extractPaper(canvas, docWidth, docHeight, scaledCorners);
// Use high quality JPEG compression to preserve image quality
finalDataUrl = resultCanvas.toDataURL('image/jpeg', 0.95);
} else {
console.log('No corners detected, using original');
mat.delete();
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} else {
console.log('No contour detected, using original');
mat.delete();
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} catch (err) {
console.warn('jscanify processing failed, using original image:', err);
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} else {
// Auto-enhance disabled or jscanify not available - use original at high quality
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
setCurrentPreview(finalDataUrl);
} finally {
setIsProcessing(false);
}
}, [autoEnhance, openCvReady]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setCurrentPreview(event.target.result as string);
}
};
reader.readAsDataURL(file);
}, []);
const addToBatch = useCallback(() => {
if (currentPreview) {
setCapturedImages((prev) => [...prev, currentPreview]);
setCurrentPreview(null);
}
}, [currentPreview]);
const uploadImages = useCallback(async () => {
const imagesToUpload = currentPreview ? [currentPreview, ...capturedImages] : capturedImages;
if (imagesToUpload.length === 0) return;
if (!sessionId) return;
setIsUploading(true);
setUploadError(null);
setUploadProgress(0);
try {
// Convert data URLs to File objects
const files: File[] = [];
for (let i = 0; i < imagesToUpload.length; i++) {
const dataUrl = imagesToUpload[i];
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `scan-${Date.now()}-${i}.jpg`, { type: 'image/jpeg' });
files.push(file);
setUploadProgress(((i + 1) / (imagesToUpload.length + 1)) * 50); // 0-50% for conversion
}
// Upload to backend
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const uploadResponse = await fetch(`/api/v1/mobile-scanner/upload/${sessionId}`, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
setUploadProgress(100);
setUploadSuccess(true);
// Close the mobile tab after successful upload
setTimeout(() => {
window.close();
// Fallback if window.close() doesn't work (some browsers block it)
if (!window.closed) {
navigate('/');
}
}, 1500);
} catch (err) {
console.error('Upload failed:', err);
setUploadError(t('mobileScanner.uploadFailed', 'Upload failed. Please try again.'));
} finally {
setIsUploading(false);
}
}, [currentPreview, capturedImages, sessionId, navigate, t]);
const retake = useCallback(() => {
setCurrentPreview(null);
}, []);
const clearBatch = useCallback(() => {
setCapturedImages([]);
}, []);
const toggleTorch = useCallback(async () => {
if (!streamRef.current) return;
try {
const videoTrack = streamRef.current.getVideoTracks()[0];
await videoTrack.applyConstraints({
advanced: [{ torch: !torchEnabled } as any], // Cast to any for experimental torch API
} as any);
setTorchEnabled(!torchEnabled);
console.log('Torch:', !torchEnabled ? 'ON' : 'OFF');
} catch (err) {
console.error('Failed to toggle torch:', err);
}
}, [torchEnabled]);
if (!sessionId) {
return (
<Box p="xl">
<Alert color="red" title={t('mobileScanner.noSession', 'Invalid Session')}>
{t('mobileScanner.noSessionMessage', 'Please scan a valid QR code to access this page.')}
</Alert>
</Box>
);
}
if (uploadSuccess) {
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
}}
>
<CheckCircleRoundedIcon style={{ fontSize: '4rem', color: 'var(--mantine-color-green-6)' }} />
<Text size="xl" fw="bold" mt="md">
{t('mobileScanner.uploadSuccess', 'Upload Successful!')}
</Text>
<Text size="sm" c="dimmed">
{t('mobileScanner.uploadSuccessMessage', 'Your images have been transferred.')}
</Text>
</Box>
);
}
return (
<Box
style={{
minHeight: '100vh',
background: 'var(--bg-background)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<Box
p="md"
style={{
background: 'var(--bg-toolbar)',
borderBottom: '1px solid var(--border-subtle)',
}}
>
<Group gap="sm" align="center">
<img
src={brandIconSrc}
alt={t('home.mobile.brandAlt', 'Stirling PDF logo')}
style={{ height: '32px', width: '32px' }}
/>
<img
src={brandTextSrc}
alt="Stirling PDF"
style={{ height: '24px' }}
/>
</Group>
</Box>
{uploadError && (
<Box p="md">
<Alert color="red" icon={<ErrorRoundedIcon />} onClose={() => setUploadError(null)} withCloseButton>
{uploadError}
</Alert>
</Box>
)}
{isUploading && (
<Box p="sm">
<Text size="sm" mb="xs">
{t('mobileScanner.uploading', 'Uploading...')}
</Text>
<Progress value={uploadProgress} animated />
</Box>
)}
{cameraError && (
<Box p="md">
<Alert color="orange" icon={<InfoRoundedIcon />}>
{cameraError}
</Alert>
</Box>
)}
{!currentPreview && (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="camera" leftSection={<PhotoCameraRoundedIcon />}>
{t('mobileScanner.camera', 'Camera')}
</Tabs.Tab>
<Tabs.Tab value="file" leftSection={<UploadRoundedIcon />}>
{t('mobileScanner.fileUpload', 'File Upload')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="camera" pt="md">
<Box style={{ position: 'relative', width: '100%', maxWidth: '100vw', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
maxHeight: '60vh',
display: 'block',
objectFit: 'contain',
}}
/>
<canvas ref={canvasRef} style={{ display: 'none' }} />
{/* Highlight overlay canvas - shows real-time document edge detection */}
<canvas
ref={highlightCanvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
opacity: showLiveDetection ? 1 : 0,
transition: 'opacity 0.2s',
objectFit: 'contain', // Maintain aspect ratio
imageRendering: 'auto', // Smooth scaling
}}
/>
</Box>
<Stack gap="sm" p="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.liveDetection', 'Live Detection')}
</Text>
<Switch
checked={showLiveDetection}
onChange={(e) => setShowLiveDetection(e.currentTarget.checked)}
disabled={!openCvReady}
/>
</Group>
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.autoEnhance', 'Auto-enhance')}
</Text>
<Switch
checked={autoEnhance}
onChange={(e) => setAutoEnhance(e.currentTarget.checked)}
disabled={!openCvReady}
/>
</Group>
{torchSupported && (
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.flashlight', 'Flashlight')}
</Text>
<Switch checked={torchEnabled} onChange={toggleTorch} />
</Group>
)}
</Stack>
<Button
fullWidth
size="lg"
onClick={captureImage}
loading={isProcessing}
>
{isProcessing
? t('mobileScanner.processing', 'Processing...')
: t('mobileScanner.capture', 'Capture Photo')}
</Button>
{autoEnhance && openCvReady && (
<Text size="xs" c="dimmed" ta="center">
{t(
'mobileScanner.autoEnhanceInfo',
'Document edges will be automatically detected and perspective corrected'
)}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="file">
<Stack gap="sm" p="sm" align="center">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
size="lg"
onClick={() => fileInputRef.current?.click()}
leftSection={<AddPhotoAlternateRoundedIcon />}
>
{t('mobileScanner.selectImage', 'Select Image')}
</Button>
</Stack>
</Tabs.Panel>
</Tabs>
)}
{currentPreview && (
<Stack gap="sm" p="sm">
<Text size="lg" fw={600}>
{t('mobileScanner.preview', 'Preview')}
</Text>
<Box
style={{
width: '100%',
background: '#000',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={currentPreview} alt="Preview" style={{ width: '100%', display: 'block' }} />
</Box>
<Group grow>
<Button variant="outline" onClick={retake}>
{t('mobileScanner.retake', 'Retake')}
</Button>
<Button variant="light" onClick={addToBatch} color="green">
{t('mobileScanner.addToBatch', 'Add to Batch')}
</Button>
</Group>
<Button fullWidth onClick={uploadImages} loading={isUploading}>
{t('mobileScanner.upload', 'Upload')}
</Button>
</Stack>
)}
{capturedImages.length > 0 && (
<Box p="sm" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t('mobileScanner.batchImages', 'Batch')} ({capturedImages.length})
</Text>
<Group gap="xs">
<Button size="xs" variant="outline" onClick={clearBatch} color="red">
{t('mobileScanner.clearBatch', 'Clear')}
</Button>
<Button size="xs" onClick={uploadImages} loading={isUploading}>
{t('mobileScanner.uploadAll', 'Upload All')}
</Button>
</Group>
</Group>
<Box style={{ display: 'flex', gap: 'var(--space-sm)', overflowX: 'auto', paddingBottom: 'var(--space-sm)' }}>
{capturedImages.map((img, idx) => (
<Box
key={idx}
style={{
minWidth: '80px',
height: '80px',
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '2px solid var(--border-subtle)',
}}
>
<img src={img} alt={`Capture ${idx + 1}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
))}
</Box>
</Box>
)}
</Box>
);
}
@@ -9,6 +9,7 @@ import { useAddPageNumbersOperation } from "@app/components/tools/addPageNumbers
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
import { useAddPageNumbersTips } from "@app/components/tooltips/useAddPageNumbersTips";
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -16,6 +17,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
const params = useAddPageNumbersParameters();
const operation = useAddPageNumbersOperation();
const pageNumbersTips = useAddPageNumbersTips();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers");
@@ -66,6 +68,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.POSITION_AND_PAGES),
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
isVisible: hasFiles || hasResults,
tooltip: pageNumbersTips,
content: (
<AddPageNumbersPositionSettings
parameters={params.parameters}
+5
View File
@@ -14,6 +14,7 @@ import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
import ObscuredOverlay from "@app/components/shared/ObscuredOverlay";
import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings";
import StampPositionFormattingSettings from "@app/components/tools/addStamp/StampPositionFormattingSettings";
import { useAddStampSetupTips, useAddStampPositionTips } from "@app/components/tooltips/useAddStampTips";
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -24,6 +25,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const params = useAddStampParameters();
const operation = useAddStampOperation();
const stampSetupTips = useAddStampSetupTips();
const stampPositionTips = useAddStampPositionTips();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-stamp");
@@ -75,6 +78,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
isVisible: hasFiles || hasResults,
tooltip: stampSetupTips,
content: (
<StampSetupSettings
parameters={params.parameters}
@@ -90,6 +94,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
isVisible: hasFiles || hasResults,
tooltip: stampPositionTips,
content: (
<Stack gap="md" justify="space-between">
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
+46 -14
View File
@@ -3,11 +3,14 @@ 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 { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -19,24 +22,53 @@ import "@app/styles/auth-theme.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>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
+5
View File
@@ -27,6 +27,11 @@ export default defineConfig(({ mode }) => {
//provides static pdfium so embedpdf can run without cdn
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
dest: 'pdfium'
},
{
// Copy jscanify vendor files to dist
src: 'public/vendor/jscanify/*',
dest: 'vendor/jscanify'
}
]
})