requestStartTour('tools')}
+ const helpButtonNode = (
+
+
- );
- }
-
- // If admin, show menu with both options
- return (
-
-
) : null;
diff --git a/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts
new file mode 100644
index 000000000..bed983fdd
--- /dev/null
+++ b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
+import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
+
+export interface ToursTooltipState {
+ tooltipOpen: boolean | undefined;
+ manualCloseOnly: boolean;
+ showCloseButton: boolean;
+ toursMenuOpen: boolean;
+ setToursMenuOpen: (open: boolean) => void;
+ handleTooltipOpenChange: (next: boolean) => void;
+}
+
+/**
+ * Encapsulates all the logic for the tours tooltip:
+ * - Shows automatically after onboarding/tour completes (once per user)
+ * - Hides while the tours menu is open
+ * - After dismissal, reverts to hover-only tooltip
+ */
+export function useToursTooltip(): ToursTooltipState {
+ const [showToursTooltip, setShowToursTooltip] = useState(false);
+ const [toursMenuOpen, setToursMenuOpen] = useState(false);
+ const tourWasOpenRef = useRef(false);
+
+ // Auto-show when a tour ends (fires once per user)
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const handleTourStateChange = (event: Event) => {
+ const { detail } = event as CustomEvent
;
+ const wasOpen = tourWasOpenRef.current;
+ tourWasOpenRef.current = detail.isOpen;
+
+ if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) {
+ setShowToursTooltip(true);
+ }
+ };
+
+ window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
+ return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
+ }, []);
+
+ // Show once after onboarding is complete
+ useEffect(() => {
+ if (isOnboardingCompleted() && !hasShownToursTooltip()) {
+ setShowToursTooltip(true);
+ }
+ }, []);
+
+ const handleDismissToursTooltip = useCallback(() => {
+ markToursTooltipShown();
+ setShowToursTooltip(false);
+ }, []);
+
+ const hasBeenDismissed = hasShownToursTooltip();
+
+ const handleTooltipOpenChange = useCallback(
+ (next: boolean) => {
+ if (!next) {
+ if (!hasBeenDismissed) {
+ handleDismissToursTooltip();
+ }
+ } else if (!hasBeenDismissed && !toursMenuOpen) {
+ setShowToursTooltip(true);
+ }
+ },
+ [hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
+ );
+
+ const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
+
+ return {
+ tooltipOpen,
+ manualCloseOnly: !hasBeenDismissed,
+ showCloseButton: !hasBeenDismissed,
+ toursMenuOpen,
+ setToursMenuOpen,
+ handleTooltipOpenChange,
+ };
+}
+
diff --git a/frontend/src/core/components/shared/tooltip/Tooltip.module.css b/frontend/src/core/components/shared/tooltip/Tooltip.module.css
index 62c4bf696..9d3bdb80a 100644
--- a/frontend/src/core/components/shared/tooltip/Tooltip.module.css
+++ b/frontend/src/core/components/shared/tooltip/Tooltip.module.css
@@ -39,7 +39,7 @@
background: var(--bg-raised);
padding: 0.25rem;
border-radius: 0.25rem;
- border: 0.0625rem solid var(--primary-color, #3b82f6);
+ border: 0.0625rem solid var(--border-default);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1;
@@ -60,6 +60,13 @@
border-color: #ef4444 !important;
}
+.tooltip-pin-button:focus,
+.tooltip-pin-button:focus-visible {
+ outline: none;
+ border-color: var(--border-default) !important;
+ background-color: var(--bg-raised) !important;
+}
+
/* Tooltip Header */
.tooltip-header {
display: flex;
@@ -91,7 +98,7 @@
/* Tooltip Body */
.tooltip-body {
- padding: 1rem !important;
+ padding: 1rem;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;
diff --git a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx
index 8bd85966a..42ee19af3 100644
--- a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx
+++ b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
+ extraRightPadding?: number;
}
export const TooltipContent: React.FC = ({
content,
tips,
+ extraRightPadding = 0,
}) => {
return (
const loadSampleFile = useCallback(async () => {
try {
+ // Hide the modal immediately so the tour targets are visible while we load
+ closeFilesModal();
const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
const blob = await response.blob();
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts
index 28f62c1c5..7b78a58d4 100644
--- a/frontend/src/core/hooks/useServerExperience.ts
+++ b/frontend/src/core/hooks/useServerExperience.ts
@@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue {
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
- const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
+ // For no-login servers, treat everyone as a regular user (no effective admin)
+ // Commented out the previous self-reported admin path to avoid elevating users.
+ // const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
+ const effectiveIsAdmin = loginEnabled ? configIsAdmin : false;
const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => {
diff --git a/frontend/src/core/services/auditService.ts b/frontend/src/core/services/auditService.ts
index ac2da176b..30951b67b 100644
--- a/frontend/src/core/services/auditService.ts
+++ b/frontend/src/core/services/auditService.ts
@@ -49,7 +49,9 @@ const auditService = {
* Get audit system status
*/
async getSystemStatus(): Promise
{
- const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard');
+ const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', {
+ suppressErrorToast: true,
+ });
const data = response.data;
// Map V1 response to expected format
diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx
index 966a82f2e..51441f228 100644
--- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx
+++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx
@@ -13,7 +13,7 @@ import {
UPGRADE_BANNER_ALERT_EVENT,
} from '@core/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
-import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage';
+import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage';
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
@@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => {
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
+ loginEnabled,
totalUsers,
userCountResolved,
userCountLoading,
@@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => {
licenseLoading,
freeTierLimit,
overFreeTierLimit,
+ weeklyActiveUsers,
scenarioKey,
} = useServerExperience();
- const onboardingComplete = hasSeenStep('welcome');
+ const onboardingComplete = isOnboardingCompleted();
+ console.log('onboardingComplete', onboardingComplete);
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === 'undefined') return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
@@ -296,8 +299,18 @@ const UpgradeBanner: React.FC = () => {
);
};
+ const suppressForNoLogin =
+ !loginEnabled ||
+ (!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
+
// Don't show on auth routes or if neither banner type should show
- if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) {
+ // Also suppress entirely for no-login servers (treat them as regular users only)
+ // and, per request, never surface upgrade messaging there when WAU > 5.
+ if (
+ onAuthRoute ||
+ suppressForNoLogin ||
+ (!friendlyVisible && !shouldEvaluateUrgent)
+ ) {
return null;
}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx
index c7142b3d2..1f71b090d 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx
@@ -23,8 +23,14 @@ const AdminAuditSection: React.FC = () => {
setError(null);
const status = await auditService.getSystemStatus();
setSystemStatus(status);
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load audit system status');
+ } catch (err: any) {
+ // Check if this is a permission/license error (403/404)
+ const status = err?.response?.status;
+ if (status === 403 || status === 404) {
+ setError('enterprise-license-required');
+ } else {
+ setError(err instanceof Error ? err.message : 'Failed to load audit system status');
+ }
} finally {
setLoading(false);
}
@@ -56,6 +62,16 @@ const AdminAuditSection: React.FC = () => {
}
if (error) {
+ if (error === 'enterprise-license-required') {
+ return (
+
+ {t(
+ 'audit.enterpriseRequiredMessage',
+ 'The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.'
+ )}
+
+ );
+ }
return (
{error}
diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
index 0aa745d54..ce6b60ab0 100644
--- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts
+++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
@@ -51,6 +51,7 @@ const BASE_NO_LOGIN_CONFIG: AppConfig = {
appVersion: '2.0.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
+ enableDesktopInstallSlide: true,
serverPort: 8080,
premiumEnabled: false,
runningProOrHigher: false,
From 33188815da3e179c089ecfffdfaf919db46fe0c4 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Mon, 15 Dec 2025 10:43:36 +0000
Subject: [PATCH 15/24] Remove UserApi mapping from proprietary signature
controller (#5239)
## Summary
- remove the UserApi composite annotation from the proprietary signature
controller to avoid duplicate request mappings
## Testing
- Not run (not requested)
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693af8cc8210832890f4787bae07d11f)
---
.../proprietary/controller/api/SignatureController.java | 2 --
1 file changed, 2 deletions(-)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
index a073c2137..53b3e1f38 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java
@@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
@@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService;
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
-@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
From 5f72c056230adb54a8ca878ece1185c1e9a5637a Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Mon, 15 Dec 2025 11:14:10 +0000
Subject: [PATCH 16/24] line art (#5052)
## Summary
- introduce a shared line art conversion interface and proprietary
ImageMagick-backed implementation
- have the compress controller optionally autowire the enterprise
service before running per-image line art processing
- remove ImageMagick command details from core by delegating conversions
through the proprietary service
## Testing
- not run (not requested)
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e)
---------
Co-authored-by: James Brunton
---
.../SPDF/config/EndpointConfiguration.java | 4 +
.../common/model/ApplicationProperties.java | 11 ++
.../service/LineArtConversionService.java | 12 ++
.../software/common/util/ProcessExecutor.java | 11 ++
.../SPDF/config/ExternalAppDepConfig.java | 2 +
.../api/misc/CompressController.java | 103 ++++++++++++
.../controller/api/misc/ConfigController.java | 6 +
.../web/ReactRoutingController.java | 4 +-
.../model/api/misc/OptimizePdfRequest.java | 22 +++
.../src/main/resources/settings.yml.template | 2 +
.../ImageMagickLineArtConversionService.java | 81 ++++++++++
docker/Dockerfile.unified | 1 +
docker/backend/Dockerfile | 1 +
docker/backend/Dockerfile.fat | 1 +
docker/embedded/Dockerfile | 1 +
docker/embedded/Dockerfile.fat | 1 +
.../public/locales/en-GB/translation.toml | 14 ++
frontend/src-tauri/tauri.conf.json | 146 +++++++++---------
.../tools/compress/CompressSettings.tsx | 75 ++++++++-
.../components/tooltips/useCompressTips.ts | 7 +
.../tools/compress/useCompressOperation.ts | 5 +
.../tools/compress/useCompressParameters.ts | 6 +
.../testing/serverExperienceSimulations.ts | 2 +-
.../testing/serverExperienceSimulations.ts | 2 +-
24 files changed, 445 insertions(+), 75 deletions(-)
create mode 100644 app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java
diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
index 35f68939c..ff48b5b2e 100644
--- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
+++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
@@ -491,6 +491,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
+ /* ImageMagick */
+ addEndpointToGroup("ImageMagick", "compress-pdf");
+
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -574,6 +577,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group)
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
+ || "ImageMagick".equals(group)
|| "rar".equals(group);
}
diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index 7e64ea9a7..e94a5f395 100644
--- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -653,6 +653,7 @@ public class ApplicationProperties {
private int weasyPrintSessionLimit;
private int installAppSessionLimit;
private int calibreSessionLimit;
+ private int imageMagickSessionLimit;
private int qpdfSessionLimit;
private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
@@ -690,6 +691,10 @@ public class ApplicationProperties {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
}
+ public int getImageMagickSessionLimit() {
+ return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
+ }
+
public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
}
@@ -719,6 +724,8 @@ public class ApplicationProperties {
@JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes;
+ private long imageMagickTimeoutMinutes;
+
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
@@ -756,6 +763,10 @@ public class ApplicationProperties {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
}
+ public long getImageMagickTimeoutMinutes() {
+ return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
+ }
+
public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
}
diff --git a/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java
new file mode 100644
index 000000000..ab4f55d2e
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java
@@ -0,0 +1,12 @@
+package stirling.software.common.service;
+
+import java.io.IOException;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+
+public interface LineArtConversionService {
+ PDImageXObject convertImageToLineArt(
+ PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
+ throws IOException;
+}
diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
index 3b94fbfbc..269441813 100644
--- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
+++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
@@ -86,6 +86,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
+ case IMAGEMAGICK ->
+ applicationProperties
+ .getProcessExecutor()
+ .getSessionLimit()
+ .getImageMagickSessionLimit();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -141,6 +146,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
+ case IMAGEMAGICK ->
+ applicationProperties
+ .getProcessExecutor()
+ .getTimeoutMinutes()
+ .getImageMagickTimeoutMinutes();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -301,6 +311,7 @@ public class ProcessExecutor {
WEASYPRINT,
INSTALL_APP,
CALIBRE,
+ IMAGEMAGICK,
TESSERACT,
QPDF,
GHOSTSCRIPT,
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
index 59c8825fc..8606cc2a9 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
@@ -46,6 +46,7 @@ public class ExternalAppDepConfig {
put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract"));
put("rar", List.of("rar")); // Required for real CBR output
+ put("magick", List.of("ImageMagick"));
}
};
}
@@ -128,6 +129,7 @@ public class ExternalAppDepConfig {
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar");
+ checkDependencyAndDisableGroup("magick");
// Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) {
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java
index 865a95c5c..9b0483da9 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java
@@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -58,6 +62,9 @@ public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
+ @Autowired(required = false)
+ private LineArtConversionService lineArtConversionService;
+
private boolean isQpdfEnabled() {
return endpointConfiguration.isGroupEnabled("qpdf");
}
@@ -66,6 +73,10 @@ public class CompressController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
+ private boolean isImageMagickEnabled() {
+ return endpointConfiguration.isGroupEnabled("ImageMagick");
+ }
+
@Data
@AllArgsConstructor
@NoArgsConstructor
@@ -660,6 +671,9 @@ public class CompressController {
Integer optimizeLevel = request.getOptimizeLevel();
String expectedOutputSizeString = request.getExpectedOutputSize();
Boolean convertToGrayscale = request.getGrayscale();
+ Boolean convertToLineArt = request.getLineArt();
+ Double lineArtThreshold = request.getLineArtThreshold();
+ Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
if (expectedOutputSizeString == null && optimizeLevel == null) {
throw new Exception("Both expected output size and optimize level are not specified");
}
@@ -689,6 +703,26 @@ public class CompressController {
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
}
+ if (Boolean.TRUE.equals(convertToLineArt)) {
+ if (lineArtConversionService == null) {
+ throw new ResponseStatusException(
+ HttpStatus.FORBIDDEN,
+ "Line art conversion is unavailable - ImageMagick service not found");
+ }
+ if (!isImageMagickEnabled()) {
+ throw new IOException(
+ "ImageMagick is not enabled but line art conversion was requested");
+ }
+ double thresholdValue =
+ lineArtThreshold == null
+ ? 55d
+ : Math.min(100d, Math.max(0d, lineArtThreshold));
+ int edgeLevel =
+ lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel));
+ currentFile =
+ applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel);
+ }
+
boolean sizeMet = false;
boolean imageCompressionApplied = false;
boolean externalCompressionApplied = false;
@@ -810,6 +844,75 @@ public class CompressController {
}
}
+ private Path applyLineArtConversion(
+ Path currentFile, List tempFiles, double threshold, int edgeLevel)
+ throws IOException {
+
+ Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf");
+ tempFiles.add(lineArtFile);
+
+ try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) {
+ Map> uniqueImages = findImages(doc);
+ CompressionStats stats = new CompressionStats();
+ stats.uniqueImagesCount = uniqueImages.size();
+ calculateImageStats(uniqueImages, stats);
+
+ Map convertedImages =
+ createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel);
+
+ replaceImages(doc, uniqueImages, convertedImages, stats);
+
+ log.info(
+ "Applied line art conversion to {} unique images ({} total references)",
+ stats.uniqueImagesCount,
+ stats.totalImages);
+
+ doc.save(lineArtFile.toString());
+ return lineArtFile;
+ }
+ }
+
+ private Map createLineArtImages(
+ PDDocument doc,
+ Map> uniqueImages,
+ CompressionStats stats,
+ double threshold,
+ int edgeLevel)
+ throws IOException {
+
+ Map convertedImages = new HashMap<>();
+
+ for (Entry> entry : uniqueImages.entrySet()) {
+ String imageHash = entry.getKey();
+ List references = entry.getValue();
+ if (references.isEmpty()) continue;
+
+ PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
+
+ int originalSize = (int) originalImage.getCOSObject().getLength();
+ stats.totalOriginalBytes += originalSize;
+
+ PDImageXObject converted =
+ lineArtConversionService.convertImageToLineArt(
+ doc, originalImage, threshold, edgeLevel);
+ convertedImages.put(imageHash, converted);
+ stats.compressedImages++;
+
+ int convertedSize = (int) converted.getCOSObject().getLength();
+ stats.totalCompressedBytes += convertedSize * references.size();
+
+ double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize);
+ log.info(
+ "Image hash {}: Line art conversion {} → {} (reduced by {}%)",
+ imageHash,
+ GeneralUtils.formatBytes(originalSize),
+ GeneralUtils.formatBytes(convertedSize),
+ String.format("%.1f", reductionPercentage));
+ }
+
+ return convertedImages;
+ }
+
// Run Ghostscript compression
private void applyGhostscriptCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List tempFiles)
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
index 91aa9924d..95486ff9b 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
@@ -230,4 +230,10 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
+
+ @GetMapping("/group-enabled")
+ public ResponseEntity isGroupEnabled(@RequestParam(name = "group") String group) {
+ boolean enabled = endpointConfiguration.isGroupEnabled(group);
+ return ResponseEntity.ok(enabled);
+ }
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
index 6373e0752..ab8f3b75b 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
@@ -74,13 +74,13 @@ public class ReactRoutingController {
}
@GetMapping(
- "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
+ "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
- "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
+ "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java
index bf96dd217..d6e5c7021 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java
@@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean grayscale = false;
+
+ @Schema(
+ description =
+ "Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "false")
+ private Boolean lineArt = false;
+
+ @Schema(
+ description = "Threshold to use for line art conversion (0-100).",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "55")
+ private Double lineArtThreshold = 55d;
+
+ @Schema(
+ description =
+ "Edge detection strength to use for line art conversion (1-3). This maps to"
+ + " ImageMagick's -edge radius.",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "1",
+ allowableValues = {"1", "2", "3"})
+ private Integer lineArtEdgeLevel = 1;
}
diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template
index 64b4bd50e..a272c54cc 100644
--- a/app/core/src/main/resources/settings.yml.template
+++ b/app/core/src/main/resources/settings.yml.template
@@ -224,6 +224,7 @@ processExecutor:
weasyPrintSessionLimit: 16
installAppSessionLimit: 1
calibreSessionLimit: 1
+ imageMagickSessionLimit: 4
ghostscriptSessionLimit: 8
ocrMyPdfSessionLimit: 2
timeoutMinutes: # Process executor timeout in minutes
@@ -233,6 +234,7 @@ processExecutor:
weasyPrinttimeoutMinutes: 30
installApptimeoutMinutes: 60
calibretimeoutMinutes: 30
+ imageMagickTimeoutMinutes: 30
tesseractTimeoutMinutes: 30
qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java
new file mode 100644
index 000000000..8ca6a83e7
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java
@@ -0,0 +1,81 @@
+package stirling.software.proprietary.service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import javax.imageio.ImageIO;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.service.LineArtConversionService;
+import stirling.software.common.util.ProcessExecutor;
+import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
+
+@Slf4j
+@Service
+public class ImageMagickLineArtConversionService implements LineArtConversionService {
+
+ @Override
+ public PDImageXObject convertImageToLineArt(
+ PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
+ throws IOException {
+
+ Path inputImage = Files.createTempFile("lineart_image_input_", ".png");
+ Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff");
+
+ try {
+ ImageIO.write(originalImage.getImage(), "png", inputImage.toFile());
+
+ List command = new ArrayList<>();
+ command.add("magick");
+ command.add(inputImage.toString());
+ command.add("-colorspace");
+ command.add("Gray");
+
+ // Edge-aware line art conversion using ImageMagick's built-in operators.
+ // -edge/-negate/-normalize are standard convert options (IM v6+/v7) that
+ // accentuate outlines before thresholding to a bilevel image.
+ command.add("-edge");
+ command.add(String.valueOf(edgeLevel));
+ command.add("-negate");
+ command.add("-normalize");
+
+ command.add("-type");
+ command.add("Bilevel");
+ command.add("-threshold");
+ command.add(String.format(Locale.ROOT, "%.1f%%", threshold));
+ command.add("-compress");
+ command.add("Group4");
+ command.add(outputImage.toString());
+
+ ProcessExecutorResult result =
+ ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK)
+ .runCommandWithOutputHandling(command);
+
+ if (result.getRc() != 0) {
+ log.warn(
+ "ImageMagick line art conversion failed with return code: {}",
+ result.getRc());
+ throw new IOException("ImageMagick line art conversion failed");
+ }
+
+ byte[] convertedBytes = Files.readAllBytes(outputImage);
+ return PDImageXObject.createFromByteArray(
+ doc, convertedBytes, originalImage.getCOSObject().toString());
+ } catch (Exception e) {
+ log.warn("ImageMagick line art conversion failed", e);
+ throw new IOException("ImageMagick line art conversion failed", e);
+ } finally {
+ Files.deleteIfExists(inputImage);
+ Files.deleteIfExists(outputImage);
+ }
+ }
+}
diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified
index 0ba7cfb3c..2968f569c 100644
--- a/docker/Dockerfile.unified
+++ b/docker/Dockerfile.unified
@@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
gcompat \
libc6-compat \
libreoffice \
+ imagemagick \
# pdftohtml
poppler-utils \
# OCR MY PDF
diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile
index f2421fa94..b946e1e61 100644
--- a/docker/backend/Dockerfile
+++ b/docker/backend/Dockerfile
@@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
+ imagemagick \
fontforge \
# pdftohtml
poppler-utils \
diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat
index c54a162da..78d395d9d 100644
--- a/docker/backend/Dockerfile.fat
+++ b/docker/backend/Dockerfile.fat
@@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
+ imagemagick \
fontforge \
# pdftohtml
poppler-utils \
diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile
index a38aee9b4..6b189f310 100644
--- a/docker/embedded/Dockerfile
+++ b/docker/embedded/Dockerfile
@@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
+ imagemagick \
fontforge \
# pdftohtml
poppler-utils \
diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat
index 67e648aee..462daa901 100644
--- a/docker/embedded/Dockerfile.fat
+++ b/docker/embedded/Dockerfile.fat
@@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
+ imagemagick \
fontforge \
# pdftohtml
poppler-utils \
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 3f269a52c..7743ca9b1 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -3729,6 +3729,16 @@ filesize = "File Size"
[compress.grayscale]
label = "Apply Grayscale for Compression"
+[compress.lineArt]
+label = "Convert images to line art"
+description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
+unavailable = "ImageMagick is not installed or enabled on this server"
+detailLevel = "Detail level"
+edgeEmphasis = "Edge emphasis"
+edgeLow = "Gentle"
+edgeMedium = "Balanced"
+edgeHigh = "Strong"
+
[compress.tooltip.header]
title = "Compress Settings Overview"
@@ -3746,6 +3756,10 @@ bullet2 = "Higher values reduce file size"
title = "Grayscale"
text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
+[compress.tooltip.lineArt]
+title = "Line Art"
+text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected."
+
[compress.error]
failed = "An error occurred while compressing the PDF."
diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json
index a7c3355d8..b10fec0e5 100644
--- a/frontend/src-tauri/tauri.conf.json
+++ b/frontend/src-tauri/tauri.conf.json
@@ -1,74 +1,82 @@
{
- "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
- "productName": "Stirling-PDF",
- "version": "2.0.0",
- "identifier": "stirling.pdf.dev",
- "build": {
- "frontendDist": "../dist",
- "devUrl": "http://localhost:5173",
- "beforeDevCommand": "npm run dev -- --mode desktop",
- "beforeBuildCommand": "npm run build -- --mode desktop"
- },
- "app": {
- "windows": [
- {
- "title": "Stirling-PDF",
- "width": 1280,
- "height": 800,
- "resizable": true,
- "fullscreen": false
- }
- ]
- },
- "bundle": {
- "active": true,
- "targets": ["deb", "rpm", "dmg", "app", "msi"],
- "icon": [
- "icons/icon.png",
- "icons/icon.icns",
- "icons/icon.ico",
- "icons/16x16.png",
- "icons/32x32.png",
- "icons/64x64.png",
- "icons/128x128.png",
- "icons/192x192.png"
- ],
- "resources": [
- "libs/*.jar",
- "runtime/jre/**/*"
- ],
- "fileAssociations": [
- {
- "ext": ["pdf"],
- "name": "PDF Document",
- "description": "Open PDF files with Stirling-PDF",
- "role": "Editor",
- "mimeType": "application/pdf"
- }
- ],
- "linux": {
- "deb": {
- "desktopTemplate": "stirling-pdf.desktop"
- }
+ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
+ "productName": "Stirling-PDF",
+ "version": "2.1.3",
+ "identifier": "stirling.pdf.dev",
+ "build": {
+ "frontendDist": "../dist",
+ "devUrl": "http://localhost:5173",
+ "beforeDevCommand": "npm run dev -- --mode desktop",
+ "beforeBuildCommand": "npm run build -- --mode desktop"
},
- "windows": {
- "certificateThumbprint": null,
- "digestAlgorithm": "sha256",
- "timestampUrl": "http://timestamp.digicert.com"
+ "app": {
+ "windows": [
+ {
+ "title": "Stirling-PDF",
+ "width": 1280,
+ "height": 800,
+ "resizable": true,
+ "fullscreen": false
+ }
+ ]
},
- "macOS": {
- "minimumSystemVersion": "10.15",
- "signingIdentity": null,
- "entitlements": null,
- "providerShortName": null
+ "bundle": {
+ "active": true,
+ "targets": [
+ "deb",
+ "rpm",
+ "dmg",
+ "app",
+ "msi"
+ ],
+ "icon": [
+ "icons/icon.png",
+ "icons/icon.icns",
+ "icons/icon.ico",
+ "icons/16x16.png",
+ "icons/32x32.png",
+ "icons/64x64.png",
+ "icons/128x128.png",
+ "icons/192x192.png"
+ ],
+ "resources": [
+ "libs/*.jar",
+ "runtime/jre/**/*"
+ ],
+ "fileAssociations": [
+ {
+ "ext": [
+ "pdf"
+ ],
+ "name": "PDF Document",
+ "description": "Open PDF files with Stirling-PDF",
+ "role": "Editor",
+ "mimeType": "application/pdf"
+ }
+ ],
+ "linux": {
+ "deb": {
+ "desktopTemplate": "stirling-pdf.desktop"
+ }
+ },
+ "windows": {
+ "certificateThumbprint": null,
+ "digestAlgorithm": "sha256",
+ "timestampUrl": "http://timestamp.digicert.com"
+ },
+ "macOS": {
+ "minimumSystemVersion": "10.15",
+ "signingIdentity": null,
+ "entitlements": null,
+ "providerShortName": null
+ }
+ },
+ "plugins": {
+ "shell": {
+ "open": true
+ },
+ "fs": {
+ "requireLiteralLeadingDot": false
+ }
}
- },
- "plugins": {
- "shell": {
- "open": true
- },
- "fs": {
- "requireLiteralLeadingDot": false
- }
- }
}
diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx
index 398e0b7b4..f444da263 100644
--- a/frontend/src/core/components/tools/compress/CompressSettings.tsx
+++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx
@@ -1,8 +1,9 @@
-import { useState } from "react";
-import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
+import { useState, useEffect } from "react";
+import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
+import apiClient from "@app/services/apiClient";
interface CompressSettingsProps {
parameters: CompressParameters;
@@ -13,6 +14,20 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation();
const [isSliding, setIsSliding] = useState(false);
+ const [imageMagickAvailable, setImageMagickAvailable] = useState(null);
+
+ useEffect(() => {
+ const checkImageMagick = async () => {
+ try {
+ const response = await apiClient.get('/api/v1/config/group-enabled?group=ImageMagick');
+ setImageMagickAvailable(response.data);
+ } catch (error) {
+ console.error('Failed to check ImageMagick availability:', error);
+ setImageMagickAvailable(true); // Optimistic fallback
+ }
+ };
+ checkImageMagick();
+ }, []);
return (
@@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
disabled={disabled}
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
/>
+ onParameterChange('lineArt', event.currentTarget.checked)}
+ disabled={disabled || imageMagickAvailable === false}
+ label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
+ description={
+ imageMagickAvailable === false
+ ? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
+ : t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
+ }
+ />
+ {parameters.lineArt && (
+
+ {t('compress.lineArt.detailLevel', 'Detail level')}
+ {
+ // Map threshold to slider position
+ const thresholdMap = [20, 35, 50, 65, 80];
+ const closest = thresholdMap.reduce((prev, curr, idx) =>
+ Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold)
+ ? idx : prev, 0);
+ return closest + 1;
+ })()}
+ onChange={(value) => {
+ // Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80%
+ const thresholdMap = [20, 35, 50, 65, 80];
+ onParameterChange('lineArtThreshold', thresholdMap[value - 1]);
+ }}
+ disabled={disabled || imageMagickAvailable === false}
+ label={null}
+ marks={[
+ { value: 1 },
+ { value: 2 },
+ { value: 3 },
+ { value: 4 },
+ { value: 5 },
+ ]}
+ />
+
+ {t('compress.lineArt.edgeEmphasis', 'Edge emphasis')}
+ onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)}
+ />
+
+ )}
);
diff --git a/frontend/src/core/components/tooltips/useCompressTips.ts b/frontend/src/core/components/tooltips/useCompressTips.ts
index c49ceaca2..3b5299e08 100644
--- a/frontend/src/core/components/tooltips/useCompressTips.ts
+++ b/frontend/src/core/components/tooltips/useCompressTips.ts
@@ -24,6 +24,13 @@ export const useCompressTips = (): TooltipContent => {
{
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.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."
+ )
}
]
};
diff --git a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts
index 5b1417b21..8e8f27b33 100644
--- a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts
+++ b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts
@@ -19,6 +19,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
}
formData.append("grayscale", parameters.grayscale.toString());
+ formData.append("lineArt", parameters.lineArt.toString());
+ if (parameters.lineArt) {
+ formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
+ formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
+ }
return formData;
};
diff --git a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts
index 1ae9298f5..16f80f1d1 100644
--- a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts
+++ b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts
@@ -4,6 +4,9 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u
export interface CompressParameters extends BaseParameters {
compressionLevel: number;
grayscale: boolean;
+ lineArt: boolean;
+ lineArtThreshold: number;
+ lineArtEdgeLevel: 1 | 2 | 3;
expectedSize: string;
compressionMethod: 'quality' | 'filesize';
fileSizeValue: string;
@@ -13,6 +16,9 @@ export interface CompressParameters extends BaseParameters {
export const defaultParameters: CompressParameters = {
compressionLevel: 5,
grayscale: false,
+ lineArt: false,
+ lineArtThreshold: 50,
+ lineArtEdgeLevel: 3,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts
index f136ce489..4bb372545 100644
--- a/frontend/src/core/testing/serverExperienceSimulations.ts
+++ b/frontend/src/core/testing/serverExperienceSimulations.ts
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: '2.0.0',
+ appVersion: '2.1.3',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
index ce6b60ab0..022910d0a 100644
--- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts
+++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: '2.0.0',
+ appVersion: '2.1.3',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
From 336ec34125523b9f5e83e4da457f64f233f95633 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Mon, 15 Dec 2025 21:55:58 +0000
Subject: [PATCH 17/24] V2 Handle SSO account restrictions in account settings
(#5225)
## Summary
- hide password and username update controls for SSO accounts and show a
managed-account notice
- prevent account update handlers from calling APIs when the user
authenticates via SSO
- expose authenticationType on the user session model and add
translations for new SSO messaging
## Testing
- not run (not requested)
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693ae8144148832888ecf128e66cd3ca)
---
.../public/locales/en-GB/translation.toml | 2 +
.../src/proprietary/auth/springAuthClient.ts | 1 +
.../config/configSections/AccountSection.tsx | 63 ++++++++++++++-----
3 files changed, 51 insertions(+), 15 deletions(-)
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 7743ca9b1..4f81d64a3 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -449,6 +449,7 @@ required = "All fields are required."
mismatch = "New passwords do not match."
error = "Unable to update password. Please verify your current password and try again."
success = "Password updated successfully. Please sign in again."
+ssoDisabled = "Password changes are managed by your identity provider."
current = "Current password"
currentPlaceholder = "Enter your current password"
new = "New password"
@@ -510,6 +511,7 @@ low = "Low"
title = "Change Credentials"
header = "Update Your Account Details"
changePassword = "You are using default login credentials. Please enter a new password"
+ssoManaged = "Your account is managed by your identity provider."
newUsername = "New Username"
oldPassword = "Current Password"
newPassword = "New Password"
diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts
index 646b71182..be404ffb4 100644
--- a/frontend/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.ts
@@ -60,6 +60,7 @@ export interface User {
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
+ authenticationType?: string;
app_metadata?: Record;
}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx
index f627302b4..95a9ee57f 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx
@@ -24,6 +24,17 @@ const AccountSection: React.FC = () => {
const [usernameError, setUsernameError] = useState('');
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
+ const authTypeFromMetadata = useMemo(() => {
+ const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined;
+ return metadata?.authenticationType ?? metadata?.authType;
+ }, [user?.app_metadata]);
+
+ const normalizedAuthType = useMemo(
+ () => (user?.authenticationType ?? authTypeFromMetadata ?? '').toLowerCase(),
+ [authTypeFromMetadata, user?.authenticationType]
+ );
+ const isSsoUser = useMemo(() => ['sso', 'oauth2', 'saml2'].includes(normalizedAuthType), [normalizedAuthType]);
+
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
const redirectToLogin = useCallback(() => {
@@ -41,6 +52,11 @@ const AccountSection: React.FC = () => {
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+ if (isSsoUser) {
+ setPasswordError(t('settings.security.password.ssoDisabled', 'Password changes are managed by your identity provider.'));
+ return;
+ }
+
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError(t('settings.security.password.required', 'All fields are required.'));
return;
@@ -81,6 +97,11 @@ const AccountSection: React.FC = () => {
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+ if (isSsoUser) {
+ setUsernameError(t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.'));
+ return;
+ }
+
if (!currentPasswordForUsername || !newUsername) {
setUsernameError(t('settings.security.password.required', 'All fields are required.'));
return;
@@ -132,23 +153,35 @@ const AccountSection: React.FC = () => {
: t('account.accountSettings', 'Account Settings')}
-
- } onClick={() => setPasswordModalOpen(true)}>
- {t('settings.security.password.update', 'Update password')}
-
+
+ {isSsoUser && (
+ } color="blue" variant="light">
+ {t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.')}
+
+ )}
- }
- onClick={() => setUsernameModalOpen(true)}
- >
- {t('account.changeUsername', 'Change username')}
-
+
+ {!isSsoUser && (
+ } onClick={() => setPasswordModalOpen(true)}>
+ {t('settings.security.password.update', 'Update password')}
+
+ )}
- } onClick={handleLogout}>
- {t('settings.general.logout', 'Log out')}
-
-
+ {!isSsoUser && (
+ }
+ onClick={() => setUsernameModalOpen(true)}
+ >
+ {t('account.changeUsername', 'Change username')}
+
+ )}
+
+ } onClick={handleLogout}>
+ {t('settings.general.logout', 'Log out')}
+
+
+
From d80e627899daf804f1390a0b75a1da3fd093aa84 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Mon, 15 Dec 2025 23:54:25 +0000
Subject: [PATCH 18/24] Cache fix issues V2 (#5237)
# Description of Changes
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---
.../common/model/ApplicationProperties.java | 50 ++-
.../software/SPDF/config/InitialSetup.java | 1 +
.../ConvertPdfJsonExceptionHandler.java | 60 ++++
.../exception/CacheUnavailableException.java | 8 +
.../service/PdfJsonConversionService.java | 310 ++++++++++++++++--
.../service/PdfJsonFallbackFontService.java | 17 +
.../service/pdfjson/PdfJsonFontService.java | 30 +-
.../pdfjson/type3/Type3LibraryStrategy.java | 16 +-
.../type3/library/Type3FontLibrary.java | 14 +-
.../src/main/resources/settings.yml.template | 42 +--
.../api/ProprietaryUIDataController.java | 27 +-
.../security/config/AccountWebController.java | 4 +-
.../configuration/SecurityConfiguration.java | 3 +-
.../controller/api/AuthController.java | 5 +-
...stomSaml2AuthenticationSuccessHandler.java | 57 +++-
.../security/saml2/Saml2Configuration.java | 73 ++++-
.../service/UserLicenseSettingsService.java | 66 ++--
.../UserLicenseSettingsServiceTest.java | 3 +
build.gradle | 2 +-
.../tools/pdfTextEditor/PdfTextEditor.tsx | 171 ++++++----
.../proprietary/auth/springAuthClient.test.ts | 2 +-
.../src/proprietary/auth/springAuthClient.ts | 17 +-
.../src/proprietary/routes/Login.test.tsx | 12 +-
frontend/src/proprietary/routes/Login.tsx | 11 +-
.../proprietary/routes/login/OAuthButtons.tsx | 21 +-
frontend/vite.config.ts | 12 +
26 files changed, 805 insertions(+), 229 deletions(-)
create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java
create mode 100644 app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java
diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index e94a5f395..72cfef1a0 100644
--- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -68,6 +68,7 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
+ private PdfEditor pdfEditor = new PdfEditor();
@Bean
public PropertySource> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -100,6 +101,46 @@ public class ApplicationProperties {
private String outputFolder;
}
+ @Data
+ public static class PdfEditor {
+ private Cache cache = new Cache();
+ private FontNormalization fontNormalization = new FontNormalization();
+ private CffConverter cffConverter = new CffConverter();
+ private Type3 type3 = new Type3();
+ private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
+
+ @Data
+ public static class Cache {
+ private long maxBytes = -1;
+ private int maxPercent = 20;
+ }
+
+ @Data
+ public static class FontNormalization {
+ private boolean enabled = false;
+ }
+
+ @Data
+ public static class CffConverter {
+ private boolean enabled = true;
+ private String method = "python";
+ private String pythonCommand = "/opt/venv/bin/python3";
+ private String pythonScript = "/scripts/convert_cff_to_ttf.py";
+ private String fontforgeCommand = "fontforge";
+ }
+
+ @Data
+ public static class Type3 {
+ private Library library = new Library();
+
+ @Data
+ public static class Library {
+ private boolean enabled = true;
+ private String index = "classpath:/type3/library/index.json";
+ }
+ }
+ }
+
@Data
public static class Legal {
private String termsAndConditions;
@@ -368,10 +409,12 @@ public class ApplicationProperties {
private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup();
private List corsAllowedOrigins = new ArrayList<>();
- private String
- frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
+ private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
+ // 'http://localhost:8080', 'https://api.example.com'). Required for
+ // SSO.
+ private String frontendUrl; // Frontend URL for invite email links (e.g.
- // falls back to backend URL.
+ // 'https://app.example.com'). If not set, falls back to backendUrl.
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -536,6 +579,7 @@ public class ApplicationProperties {
@ToString.Exclude private String key;
private String UUID;
private String appVersion;
+ private Boolean isNewServer;
}
// TODO: Remove post migration
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java
index 88755f950..2cded405f 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java
@@ -94,6 +94,7 @@ public class InitialSetup {
}
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
+ applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
}
public static boolean isNewServer() {
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java
new file mode 100644
index 000000000..c82fe19e1
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java
@@ -0,0 +1,60 @@
+package stirling.software.SPDF.controller.api.converters;
+
+import java.nio.charset.StandardCharsets;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.SPDF.exception.CacheUnavailableException;
+
+@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
+@Slf4j
+@RequiredArgsConstructor
+public class ConvertPdfJsonExceptionHandler {
+
+ private final ObjectMapper objectMapper;
+
+ @ExceptionHandler(CacheUnavailableException.class)
+ @ResponseBody
+ public ResponseEntity handleCacheUnavailable(CacheUnavailableException ex) {
+ try {
+ byte[] body =
+ objectMapper.writeValueAsBytes(
+ java.util.Map.of(
+ "error", "cache_unavailable",
+ "action", "reupload",
+ "message", ex.getMessage()));
+ return ResponseEntity.status(HttpStatus.GONE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(body);
+ } catch (Exception e) {
+ log.warn("Failed to serialize cache_unavailable response", e);
+ var fallbackBody =
+ java.util.Map.of(
+ "error", "cache_unavailable",
+ "action", "reupload",
+ "message", String.valueOf(ex.getMessage()));
+ try {
+ return ResponseEntity.status(HttpStatus.GONE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(objectMapper.writeValueAsBytes(fallbackBody));
+ } catch (Exception ignored) {
+ // Truly last-ditch fallback
+ return ResponseEntity.status(HttpStatus.GONE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ "{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}"
+ .getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java
new file mode 100644
index 000000000..fd5d77677
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java
@@ -0,0 +1,8 @@
+package stirling.software.SPDF.exception;
+
+public class CacheUnavailableException extends RuntimeException {
+
+ public CacheUnavailableException(String message) {
+ super(message);
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java
index 623b99260..604e9ba38 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java
@@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.DateConverter;
import org.apache.pdfbox.util.Matrix;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -144,15 +143,23 @@ public class PdfJsonConversionService {
private final PdfJsonFontService fontService;
private final Type3FontConversionService type3FontConversionService;
private final Type3GlyphExtractor type3GlyphExtractor;
+ private final stirling.software.common.model.ApplicationProperties applicationProperties;
private final Map type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map> type3GlyphCoverageCache = new ConcurrentHashMap<>();
- @Value("${stirling.pdf.json.font-normalization.enabled:true}")
private boolean fontNormalizationEnabled;
+ private long cacheMaxBytes;
+ private int cacheMaxPercent;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map documentCache = new ConcurrentHashMap<>();
+ private final java.util.LinkedHashMap lruCache =
+ new java.util.LinkedHashMap<>(16, 0.75f, true);
+ private final Object cacheLock = new Object();
+ private volatile long currentCacheBytes = 0L;
+ private volatile long cacheBudgetBytes = -1L;
+
private volatile boolean ghostscriptAvailable;
private static final float FLOAT_EPSILON = 0.0001f;
@@ -161,7 +168,23 @@ public class PdfJsonConversionService {
@PostConstruct
private void initializeToolAvailability() {
+ loadConfigurationFromProperties();
initializeGhostscriptAvailability();
+ initializeCacheBudget();
+ }
+
+ private void loadConfigurationFromProperties() {
+ stirling.software.common.model.ApplicationProperties.PdfEditor cfg =
+ applicationProperties.getPdfEditor();
+ if (cfg != null) {
+ fontNormalizationEnabled = cfg.getFontNormalization().isEnabled();
+ cacheMaxBytes = cfg.getCache().getMaxBytes();
+ cacheMaxPercent = cfg.getCache().getMaxPercent();
+ } else {
+ fontNormalizationEnabled = false;
+ cacheMaxBytes = -1;
+ cacheMaxPercent = 20;
+ }
}
private void initializeGhostscriptAvailability() {
@@ -202,6 +225,25 @@ public class PdfJsonConversionService {
}
}
+ private void initializeCacheBudget() {
+ long effective = -1L;
+ if (cacheMaxBytes > 0) {
+ effective = cacheMaxBytes;
+ } else if (cacheMaxPercent > 0) {
+ long maxMem = Runtime.getRuntime().maxMemory();
+ effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100);
+ }
+ cacheBudgetBytes = effective;
+ if (cacheBudgetBytes > 0) {
+ log.info(
+ "PDF JSON cache budget configured: {} bytes (source: {})",
+ cacheBudgetBytes,
+ cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
+ } else {
+ log.info("PDF JSON cache budget: unlimited");
+ }
+ }
+
public byte[] convertPdfToJson(MultipartFile file) throws IOException {
return convertPdfToJson(file, null, false);
}
@@ -236,7 +278,10 @@ public class PdfJsonConversionService {
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else {
jobId = contextJobId;
- log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId);
+ log.info(
+ "Starting PDF to JSON conversion, jobId from context: {} (lightweight={})",
+ jobId,
+ lightweight);
}
Consumer progress =
@@ -318,9 +363,9 @@ public class PdfJsonConversionService {
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
int totalPages = document.getNumberOfPages();
- // Only use lazy images for real async jobs where client can access the cache
- // Synchronous calls with synthetic jobId should do full extraction
- boolean useLazyImages = totalPages > 5 && isRealJobId;
+ // Always enable lazy mode for real async jobs so cache is available regardless of
+ // page count. Synchronous calls with synthetic jobId still do full extraction.
+ boolean useLazyImages = isRealJobId;
Map fontCache = new IdentityHashMap<>();
Map imageCache = new IdentityHashMap<>();
log.debug(
@@ -403,6 +448,11 @@ public class PdfJsonConversionService {
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
+ log.info(
+ "Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
+ jobId,
+ useLazyImages,
+ isRealJobId);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -435,16 +485,23 @@ public class PdfJsonConversionService {
cachedPdfBytes = Files.readAllBytes(workingPath);
}
CachedPdfDocument cached =
- new CachedPdfDocument(
- cachedPdfBytes, docMetadata, fonts, pageFontResources);
- documentCache.put(jobId, cached);
- log.debug(
- "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}",
- cachedPdfBytes.length,
+ buildCachedDocument(
+ jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
+ putCachedDocument(jobId, cached);
+ log.info(
+ "Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
+ cached.getPdfSize(),
totalPages,
fonts.size(),
- jobId);
+ jobId,
+ cached.isDiskBacked());
scheduleDocumentCleanup(jobId);
+ } else {
+ log.warn(
+ "Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
+ useLazyImages,
+ isRealJobId,
+ jobId);
}
if (lightweight) {
@@ -2973,6 +3030,139 @@ public class PdfJsonConversionService {
}
}
+ // Cache helpers
+ private CachedPdfDocument buildCachedDocument(
+ String jobId,
+ byte[] pdfBytes,
+ PdfJsonDocumentMetadata metadata,
+ Map fonts,
+ Map> pageFontResources)
+ throws IOException {
+ if (pdfBytes == null) {
+ throw new IllegalArgumentException("pdfBytes must not be null");
+ }
+ long budget = cacheBudgetBytes;
+ // If single document is larger than budget, spill straight to disk
+ if (budget > 0 && pdfBytes.length > budget) {
+ TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache");
+ Files.write(tempFile.getPath(), pdfBytes);
+ log.debug(
+ "Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}",
+ pdfBytes.length,
+ budget,
+ jobId);
+ return new CachedPdfDocument(
+ null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources);
+ }
+ return new CachedPdfDocument(
+ pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources);
+ }
+
+ private void putCachedDocument(String jobId, CachedPdfDocument cached) {
+ synchronized (cacheLock) {
+ CachedPdfDocument existing = documentCache.put(jobId, cached);
+ if (existing != null) {
+ lruCache.remove(jobId);
+ currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize());
+ existing.close();
+ }
+ lruCache.put(jobId, cached);
+ currentCacheBytes += cached.getInMemorySize();
+ enforceCacheBudget();
+ }
+ }
+
+ private CachedPdfDocument getCachedDocument(String jobId) {
+ synchronized (cacheLock) {
+ CachedPdfDocument cached = documentCache.get(jobId);
+ if (cached != null) {
+ lruCache.remove(jobId);
+ lruCache.put(jobId, cached);
+ }
+ return cached;
+ }
+ }
+
+ private void enforceCacheBudget() {
+ if (cacheBudgetBytes <= 0) {
+ return;
+ }
+ // Must be called under cacheLock
+ java.util.Iterator> it =
+ lruCache.entrySet().iterator();
+ while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) {
+ java.util.Map.Entry entry = it.next();
+ it.remove();
+ CachedPdfDocument removed = entry.getValue();
+ documentCache.remove(entry.getKey(), removed);
+ currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
+ removed.close();
+ log.warn(
+ "Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)",
+ entry.getKey(),
+ cacheBudgetBytes,
+ currentCacheBytes);
+ }
+ if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) {
+ // Spill the most recently used large entry to disk
+ String key =
+ lruCache.entrySet().stream()
+ .reduce((first, second) -> second)
+ .map(java.util.Map.Entry::getKey)
+ .orElse(null);
+ if (key != null) {
+ CachedPdfDocument doc = lruCache.get(key);
+ if (doc != null && doc.getInMemorySize() > 0) {
+ try {
+ CachedPdfDocument diskDoc =
+ buildCachedDocument(
+ key,
+ doc.getPdfBytes(),
+ doc.getMetadata(),
+ doc.getFonts(),
+ doc.getPageFontResources());
+ lruCache.put(key, diskDoc);
+ documentCache.put(key, diskDoc);
+ currentCacheBytes =
+ Math.max(0L, currentCacheBytes - doc.getInMemorySize())
+ + diskDoc.getInMemorySize();
+ doc.close();
+ log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key);
+ } catch (IOException ex) {
+ log.warn(
+ "Failed to spill cached PDF for jobId {} to disk: {}",
+ key,
+ ex.getMessage());
+ }
+ }
+ }
+ }
+ }
+
+ private void removeCachedDocument(String jobId) {
+ log.warn(
+ "removeCachedDocument called for jobId: {} [CALLER: {}]",
+ jobId,
+ Thread.currentThread().getStackTrace()[2].toString());
+ CachedPdfDocument removed = null;
+ synchronized (cacheLock) {
+ removed = documentCache.remove(jobId);
+ if (removed != null) {
+ lruCache.remove(jobId);
+ currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
+ log.warn(
+ "Removed cached document for jobId: {} (size={} bytes)",
+ jobId,
+ removed.getInMemorySize());
+ } else {
+ log.warn("Attempted to remove jobId: {} but it was not in cache", jobId);
+ }
+ }
+ if (removed != null) {
+ removed.close();
+ }
+ }
+
private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException {
if (element.getCharacterSpacing() != null) {
@@ -5311,6 +5501,8 @@ public class PdfJsonConversionService {
*/
private static class CachedPdfDocument {
private final byte[] pdfBytes;
+ private final TempFile pdfTempFile;
+ private final long pdfSize;
private final PdfJsonDocumentMetadata metadata;
private final Map fonts; // Font map with UIDs for consistency
private final Map> pageFontResources; // Page font resources
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
public CachedPdfDocument(
byte[] pdfBytes,
+ TempFile pdfTempFile,
+ long pdfSize,
PdfJsonDocumentMetadata metadata,
Map fonts,
Map> pageFontResources) {
this.pdfBytes = pdfBytes;
+ this.pdfTempFile = pdfTempFile;
+ this.pdfSize = pdfSize;
this.metadata = metadata;
// Create defensive copies to prevent mutation of shared maps
this.fonts =
@@ -5336,8 +5532,14 @@ public class PdfJsonConversionService {
}
// Getters return defensive copies to prevent external mutation
- public byte[] getPdfBytes() {
- return pdfBytes;
+ public byte[] getPdfBytes() throws IOException {
+ if (pdfBytes != null) {
+ return pdfBytes;
+ }
+ if (pdfTempFile != null) {
+ return Files.readAllBytes(pdfTempFile.getPath());
+ }
+ throw new IOException("Cached PDF backing missing");
}
public PdfJsonDocumentMetadata getMetadata() {
@@ -5352,6 +5554,18 @@ public class PdfJsonConversionService {
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources);
}
+ public long getPdfSize() {
+ return pdfSize;
+ }
+
+ public long getInMemorySize() {
+ return pdfBytes != null ? pdfBytes.length : 0L;
+ }
+
+ public boolean isDiskBacked() {
+ return pdfBytes == null && pdfTempFile != null;
+ }
+
public long getTimestamp() {
return timestamp;
}
@@ -5363,7 +5577,19 @@ public class PdfJsonConversionService {
public CachedPdfDocument withUpdatedFonts(
byte[] nextBytes, Map nextFonts) {
Map fontsToUse = nextFonts != null ? nextFonts : this.fonts;
- return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources);
+ return new CachedPdfDocument(
+ nextBytes,
+ null,
+ nextBytes != null ? nextBytes.length : 0,
+ metadata,
+ fontsToUse,
+ pageFontResources);
+ }
+
+ public void close() {
+ if (pdfTempFile != null) {
+ pdfTempFile.close();
+ }
}
}
@@ -5444,14 +5670,15 @@ public class PdfJsonConversionService {
// Cache PDF bytes, metadata, and fonts for lazy page loading
if (jobId != null) {
CachedPdfDocument cached =
- new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources);
- documentCache.put(jobId, cached);
+ buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources);
+ putCachedDocument(jobId, cached);
log.debug(
- "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}",
- pdfBytes.length,
+ "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})",
+ cached.getPdfSize(),
totalPages,
fonts.size(),
- jobId);
+ jobId,
+ cached.isDiskBacked());
// Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId);
@@ -5466,9 +5693,10 @@ public class PdfJsonConversionService {
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
- CachedPdfDocument cached = documentCache.get(jobId);
+ CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
- throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
+ throw new stirling.software.SPDF.exception.CacheUnavailableException(
+ "No cached document found for jobId: " + jobId);
}
int pageIndex = pageNumber - 1;
@@ -5480,8 +5708,8 @@ public class PdfJsonConversionService {
}
log.debug(
- "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})",
- cached.getPdfBytes().length,
+ "Loading PDF from {} to extract page {} (jobId: {})",
+ cached.isDiskBacked() ? "disk cache" : "memory cache",
pageNumber,
jobId);
@@ -5627,10 +5855,21 @@ public class PdfJsonConversionService {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
- CachedPdfDocument cached = documentCache.get(jobId);
+ log.info("Looking up cache for jobId: {}", jobId);
+ CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
- throw new IllegalArgumentException("No cached document available for jobId: " + jobId);
+ log.error(
+ "Cache not found for jobId: {}. Available cache keys: {}",
+ jobId,
+ documentCache.keySet());
+ throw new stirling.software.SPDF.exception.CacheUnavailableException(
+ "No cached document available for jobId: " + jobId);
}
+ log.info(
+ "Found cached document for jobId: {} (size={}, diskBacked={})",
+ jobId,
+ cached.getPdfSize(),
+ cached.isDiskBacked());
if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}",
@@ -5709,7 +5948,14 @@ public class PdfJsonConversionService {
document.save(baos);
byte[] updatedBytes = baos.toByteArray();
- documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts));
+ CachedPdfDocument updated =
+ buildCachedDocument(
+ jobId,
+ updatedBytes,
+ cached.getMetadata(),
+ mergedFonts,
+ cached.getPageFontResources());
+ putCachedDocument(jobId, updated);
// Clear Type3 cache entries for this incremental update
clearType3CacheEntriesForJob(updateJobId);
@@ -5724,11 +5970,13 @@ public class PdfJsonConversionService {
/** Clears a cached document. */
public void clearCachedDocument(String jobId) {
- CachedPdfDocument cached = documentCache.remove(jobId);
+ CachedPdfDocument cached = getCachedDocument(jobId);
+ removeCachedDocument(jobId);
if (cached != null) {
log.debug(
- "Removed cached PDF bytes ({} bytes) for jobId: {}",
- cached.getPdfBytes().length,
+ "Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}",
+ cached.getPdfSize(),
+ cached.isDiskBacked(),
jobId);
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java
index 107abbe2b..e4baee055 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java
@@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService {
"ttf")));
private final ResourceLoader resourceLoader;
+ private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
+ private String legacyFallbackFontLocation;
+
private String fallbackFontLocation;
private final Map fallbackFontCache = new ConcurrentHashMap<>();
+ @jakarta.annotation.PostConstruct
+ private void loadConfig() {
+ String configured = null;
+ if (applicationProperties.getPdfEditor() != null) {
+ configured = applicationProperties.getPdfEditor().getFallbackFont();
+ }
+ if (configured != null && !configured.isBlank()) {
+ fallbackFontLocation = configured;
+ } else {
+ fallbackFontLocation = legacyFallbackFontLocation;
+ }
+ log.info("Using fallback font location: {}", fallbackFontLocation);
+ }
+
public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
index 1a9f7f698..6a56bad09 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
@@ -5,7 +5,6 @@ import java.nio.file.Files;
import java.util.Base64;
import java.util.Locale;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager;
public class PdfJsonFontService {
private final TempFileManager tempFileManager;
+ private final stirling.software.common.model.ApplicationProperties applicationProperties;
- @Getter
- @Value("${stirling.pdf.json.cff-converter.enabled:true}")
- private boolean cffConversionEnabled;
+ @Getter private boolean cffConversionEnabled;
- @Getter
- @Value("${stirling.pdf.json.cff-converter.method:python}")
- private String cffConverterMethod;
+ @Getter private String cffConverterMethod;
- @Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand;
- @Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript;
- @Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable;
@@ -48,6 +41,7 @@ public class PdfJsonFontService {
@PostConstruct
private void initialiseCffConverterAvailability() {
+ loadConfiguration();
if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false;
@@ -77,6 +71,22 @@ public class PdfJsonFontService {
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
}
+ private void loadConfiguration() {
+ if (applicationProperties.getPdfEditor() != null
+ && applicationProperties.getPdfEditor().getCffConverter() != null) {
+ var cfg = applicationProperties.getPdfEditor().getCffConverter();
+ this.cffConversionEnabled = cfg.isEnabled();
+ this.cffConverterMethod = cfg.getMethod();
+ this.pythonCommand = cfg.getPythonCommand();
+ this.pythonScript = cfg.getPythonScript();
+ this.fontforgeCommand = cfg.getFontforgeCommand();
+ } else {
+ // Use defaults when config is not available
+ this.cffConversionEnabled = false;
+ log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled");
+ }
+ }
+
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn(
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java
index 4385e5725..b4e8f9d95 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java
@@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary;
+ private final stirling.software.common.model.ApplicationProperties applicationProperties;
- @Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled;
@Override
@@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
return enabled && fontLibrary != null && fontLibrary.isLoaded();
}
+ @jakarta.annotation.PostConstruct
+ private void loadConfiguration() {
+ if (applicationProperties.getPdfEditor() != null
+ && applicationProperties.getPdfEditor().getType3() != null
+ && applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
+ var cfg = applicationProperties.getPdfEditor().getType3().getLibrary();
+ this.enabled = cfg.isEnabled();
+ } else {
+ this.enabled = false;
+ log.warn("PdfEditor Type3 library configuration not available, disabled");
+ }
+ }
+
@Override
public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java
index 32a6abec2..f00c729a2 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java
@@ -14,7 +14,6 @@ import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
@@ -34,8 +33,8 @@ public class Type3FontLibrary {
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
+ private final stirling.software.common.model.ApplicationProperties applicationProperties;
- @Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
private String indexLocation;
private final Map signatureIndex = new ConcurrentHashMap<>();
@@ -44,6 +43,17 @@ public class Type3FontLibrary {
@jakarta.annotation.PostConstruct
void initialise() {
+ if (applicationProperties.getPdfEditor() != null
+ && applicationProperties.getPdfEditor().getType3() != null
+ && applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
+ this.indexLocation =
+ applicationProperties.getPdfEditor().getType3().getLibrary().getIndex();
+ } else {
+ log.warn(
+ "[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled");
+ entries = List.of();
+ return;
+ }
Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template
index a272c54cc..dffebe1bb 100644
--- a/app/core/src/main/resources/settings.yml.template
+++ b/app/core/src/main/resources/settings.yml.template
@@ -58,6 +58,8 @@ security:
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
+ # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
+ # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
@@ -132,8 +134,9 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
- corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
- frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
+ corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
+ backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
+ frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -179,23 +182,6 @@ system:
databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
-stirling:
- pdf:
- fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
- json:
- font-normalization:
- enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
- cff-converter:
- enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility
- method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
- python-command: /opt/venv/bin/python3 # Python interpreter path
- python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
- fontforge-command: fontforge # Override if FontForge is installed under a different name/path
- type3:
- library:
- enabled: true # Match common Type3 fonts against the built-in library of converted programs
- index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
-
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
@@ -239,3 +225,21 @@ processExecutor:
qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 30
+
+pdfEditor:
+ fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
+ cache:
+ max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap
+ max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0
+ font-normalization:
+ enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
+ cff-converter:
+ enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility
+ method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
+ python-command: /opt/venv/bin/python3 # Python interpreter path
+ python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
+ fontforge-command: fontforge # Override if FontForge is installed under a different name/path
+ type3:
+ library:
+ enabled: true # Match common Type3 fonts against the built-in library of converted programs
+ index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
index 88fddb7ea..e13d807da 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java
@@ -94,6 +94,22 @@ public class ProprietaryUIDataController {
this.auditRepository = auditRepository;
}
+ /**
+ * Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set,
+ * otherwise defaults to http://localhost:8080
+ */
+ private String getBackendBaseUrl() {
+ String backendUrl = applicationProperties.getSystem().getBackendUrl();
+
+ // If backendUrl is configured, use it
+ if (backendUrl != null && !backendUrl.trim().isEmpty()) {
+ return backendUrl.trim();
+ }
+
+ // For development, default to localhost:8080 (backend port)
+ return "http://localhost:8080";
+ }
+
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@@ -185,14 +201,17 @@ public class ProprietaryUIDataController {
}
SAML2 saml2 = securityProps.getSaml2();
- if (securityProps.isSaml2Active()
- && applicationProperties.getSystem().getEnableAlphaFunctionality()
- && applicationProperties.getPremium().isEnabled()) {
+ if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
+ // For SAML, we need to use the backend URL directly, not a relative path
+ // This ensures Spring Security generates the correct ACS URL
+ String backendUrl = getBackendBaseUrl();
+ String fullSamlPath = backendUrl + saml2AuthenticationPath;
+
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
- providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
+ providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
index d035dbc58..17857fc85 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
@@ -120,9 +120,7 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2();
- if (securityProps.isSaml2Active()
- && applicationProperties.getSystem().getEnableAlphaFunctionality()
- && applicationProperties.getPremium().isEnabled()) {
+ if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
index 257d243ea..1226237c8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
@@ -334,7 +334,8 @@ public class SecurityConfiguration {
securityProperties.getSaml2(),
userService,
jwtService,
- licenseSettingsService))
+ licenseSettingsService,
+ applicationProperties))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java
index de6428554..c3e11c3ab 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java
@@ -244,10 +244,13 @@ public class AuthController {
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
+ userMap.put(
+ "authenticationType",
+ user.getAuthenticationType()); // Expose authentication type for SSO detection
// Add metadata for OAuth compatibility
Map appMetadata = new HashMap<>();
- appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
+ appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata);
return userMap;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
index e8bce579a..3c63f1bf4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
@@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
+ private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
- response.sendRedirect(
- request.getContextPath() + "/logout?saml2RequiresLicense=true");
+ String origin = resolveOrigin(request);
+ response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isSamlEligible(null)) {
@@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
- response.sendRedirect(
- request.getContextPath() + "/logout?saml2RequiresLicense=true");
+ String origin = resolveOrigin(request);
+ response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
@@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
- response.sendRedirect(
- contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
+ String origin = resolveOrigin(request);
+ response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
return;
}
try {
- if (!userExists || saml2Properties.getBlockRegistration()) {
- log.debug("Registration blocked for new user: {}", username);
- response.sendRedirect(
- contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
+ // Block new users only if: blockRegistration is true OR autoCreateUser is false
+ if (!userExists
+ && (saml2Properties.getBlockRegistration()
+ || !saml2Properties.getAutoCreateUser())) {
+ log.debug(
+ "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
+ username,
+ saml2Properties.getBlockRegistration(),
+ saml2Properties.getAutoCreateUser());
+ String origin = resolveOrigin(request);
+ response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
- response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
+ String origin = resolveOrigin(request);
+ response.sendRedirect(origin + "/logout?maxUsersReached=true");
return;
}
@@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
- String origin =
- resolveForwardedOrigin(request)
- .orElseGet(
- () ->
- resolveOriginFromReferer(request)
- .orElseGet(() -> buildOriginFromRequest(request)));
+ String origin = resolveOrigin(request);
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
+ /**
+ * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
+ * then falls back to detecting from request headers.
+ */
+ private String resolveOrigin(HttpServletRequest request) {
+ // First check if frontendUrl is configured
+ String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
+ if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
+ return configuredFrontendUrl.trim();
+ }
+
+ // Fall back to auto-detection from request headers
+ return resolveForwardedOrigin(request)
+ .orElseGet(
+ () ->
+ resolveOriginFromReferer(request)
+ .orElseGet(() -> buildOriginFromRequest(request)));
+ }
+
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java
index 9d21f88a3..99be4b5b0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java
@@ -41,22 +41,74 @@ public class Saml2Configuration {
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
- X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert());
+
+ log.info(
+ "Initializing SAML2 configuration with registration ID: {}",
+ samlConf.getRegistrationId());
+
+ // Load IdP certificate
+ X509Certificate idpCert;
+ try {
+ Resource idpCertResource = samlConf.getIdpCert();
+ log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
+ if (!idpCertResource.exists()) {
+ log.error(
+ "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
+ throw new IllegalStateException(
+ "SAML2 IdP certificate file does not exist: "
+ + idpCertResource.getDescription());
+ }
+ idpCert = CertificateUtils.readCertificate(idpCertResource);
+ log.info(
+ "Successfully loaded IdP certificate. Subject: {}",
+ idpCert.getSubjectX500Principal().getName());
+ } catch (Exception e) {
+ log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
+ throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
+ }
+
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
+
+ // Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
- Saml2X509Credential signingCredential =
- new Saml2X509Credential(
- CertificateUtils.readPrivateKey(privateKeyResource),
- CertificateUtils.readCertificate(certificateResource),
- Saml2X509CredentialType.SIGNING);
+
+ log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
+ if (!privateKeyResource.exists()) {
+ log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
+ throw new IllegalStateException(
+ "SAML2 SP private key file does not exist: "
+ + privateKeyResource.getDescription());
+ }
+
+ log.info("Loading SP certificate from: {}", certificateResource.getDescription());
+ if (!certificateResource.exists()) {
+ log.error(
+ "SAML2 SP certificate not found at: {}", certificateResource.getDescription());
+ throw new IllegalStateException(
+ "SAML2 SP certificate file does not exist: "
+ + certificateResource.getDescription());
+ }
+
+ Saml2X509Credential signingCredential;
+ try {
+ signingCredential =
+ new Saml2X509Credential(
+ CertificateUtils.readPrivateKey(privateKeyResource),
+ CertificateUtils.readCertificate(certificateResource),
+ Saml2X509CredentialType.SIGNING);
+ log.info("Successfully loaded SP credentials");
+ } catch (Exception e) {
+ log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
+ throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
+ }
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(samlConf.getIdpIssuer())
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
- .singleLogoutServiceResponseLocation("http://localhost:8080/login")
+ .singleLogoutServiceResponseLocation("{baseUrl}/login")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
@@ -75,9 +127,14 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
- "http://localhost:8080/login")
+ "{baseUrl}/login")
.wantAuthnRequestsSigned(true))
.build();
+
+ log.info(
+ "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
+ samlConf.getRegistrationId(),
+ samlConf.getIdpIssuer());
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java
index aa794e699..54660a1cc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java
@@ -177,6 +177,13 @@ public class UserLicenseSettingsService {
*/
@Transactional
public void grandfatherExistingOAuthUsers() {
+ // Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install
+ Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer();
+ if (Boolean.TRUE.equals(isNewServer)) {
+ log.info("Fresh V2 installation detected - skipping OAuth user grandfathering");
+ return;
+ }
+
UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration
@@ -348,30 +355,22 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "";
log.info("OAuth eligibility check for user: {}", username);
- // Grandfathered users always have OAuth access
- if (user != null && user.isOauthGrandfathered()) {
- log.debug("User {} is grandfathered for OAuth", user.getUsername());
+ // Check license first - if paying, they're eligible (no need to check grandfathering)
+ boolean hasPaid = hasPaidLicense();
+ if (hasPaid) {
+ log.debug("User {} eligible for OAuth via paid license", username);
return true;
}
- // todo: remove
- if (user != null) {
- log.info(
- "User {} is NOT grandfathered (isOauthGrandfathered={})",
- username,
- user.isOauthGrandfathered());
- } else {
- log.info("New user attempting OAuth login - checking license requirement");
+ // No license - check if grandfathered (fallback for V1 users)
+ if (user != null && user.isOauthGrandfathered()) {
+ log.info("User {} eligible for OAuth via grandfathering (no paid license)", username);
+ return true;
}
- // Users can use OAuth with SERVER or ENTERPRISE license
- boolean hasPaid = hasPaidLicense();
- log.info(
- "OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
- hasPaid,
- username,
- hasPaid);
- return hasPaid;
+ // Not grandfathered and no license
+ log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username);
+ return false;
}
/**
@@ -391,29 +390,26 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "";
log.info("SAML2 eligibility check for user: {}", username);
- // Grandfathered users always have SAML access
- if (user != null && user.isOauthGrandfathered()) {
- log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
+ // Check license first - if paying, they're eligible (no need to check grandfathering)
+ boolean hasEnterprise = hasEnterpriseLicense();
+ if (hasEnterprise) {
+ log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
return true;
}
- if (user != null) {
+ // No license - check if grandfathered (fallback for V1 users)
+ if (user != null && user.isOauthGrandfathered()) {
log.info(
- "User {} is NOT grandfathered (isOauthGrandfathered={})",
- username,
- user.isOauthGrandfathered());
- } else {
- log.info("New user attempting SAML2 login - checking license requirement");
+ "User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
+ username);
+ return true;
}
- // Users can use SAML only with ENTERPRISE license
- boolean hasEnterprise = hasEnterpriseLicense();
+ // Not grandfathered and no license
log.info(
- "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
- hasEnterprise,
- username,
- hasEnterprise);
- return hasEnterprise;
+ "User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered",
+ username);
+ return false;
}
/**
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
index 7f9445ad7..a7f8042f6 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java
@@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest {
@Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium;
+ @Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider licenseKeyCheckerProvider;
@@ -49,6 +50,8 @@ class UserLicenseSettingsServiceTest {
mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium);
+ when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
+ when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
diff --git a/build.gradle b/build.gradle
index 90fde88e5..17bcdb878 100644
--- a/build.gradle
+++ b/build.gradle
@@ -59,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
- version = '2.1.3'
+ version = '2.1.4'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
index 533dc644b..422b06603 100644
--- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
+++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
@@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalImagesRef = useRef([]);
const originalGroupsRef = useRef([]);
const imagesByPageRef = useRef([]);
+ const lastLoadedFileRef = useRef(null);
const autoLoadKeyRef = useRef(null);
const sourceFileIdRef = useRef(null);
const loadRequestIdRef = useRef(0);
@@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const pagePreviewsRef = useRef