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 0f350d7b4d..b342fdcb46 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
@@ -67,19 +67,19 @@ public class CustomSaml2AuthenticationSuccessHandler
boolean userExists = userService.usernameExistsIgnoreCase(username);
- // Check if user is eligible for SAML (grandfathered or system has paid license)
+ // Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
- if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
- // User is not grandfathered and no paid license - block SAML login
+ if (user != null && !licenseSettingsService.isSamlEligible(user)) {
+ // User is not grandfathered and no ENTERPRISE license - block SAML login
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
- } else if (!licenseSettingsService.isOAuthEligible(null)) {
- // No existing user and no paid license -> block auto creation
+ } else if (!licenseSettingsService.isSamlEligible(null)) {
+ // No existing user and no ENTERPRISE license -> block auto creation
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
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 1cddefde3b..d3bade89c0 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
@@ -331,17 +331,17 @@ public class UserLicenseSettingsService {
}
/**
- * Checks if a user is eligible to use OAuth/SAML authentication.
+ * Checks if a user is eligible to use OAuth authentication.
*
*
A user is eligible if:
*
*
* They are grandfathered for OAuth (existing user before policy change), OR
- * The system has an ENTERPRISE license (SSO is enterprise-only)
+ * The system has a paid license (SERVER or ENTERPRISE)
*
*
* @param user The user to check
- * @return true if the user can use OAuth/SAML
+ * @return true if the user can use OAuth
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
// Grandfathered users always have OAuth access
@@ -350,10 +350,36 @@ public class UserLicenseSettingsService {
return true;
}
- // Users can use OAuth/SAML only if system has ENTERPRISE license
- boolean hasEnterpriseLicense = hasEnterpriseLicense();
- log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
- return hasEnterpriseLicense;
+ // Users can use OAuth with SERVER or ENTERPRISE license
+ boolean hasPaid = hasPaidLicense();
+ log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaid);
+ return hasPaid;
+ }
+
+ /**
+ * Checks if a user is eligible to use SAML authentication.
+ *
+ * A user is eligible if:
+ *
+ *
+ * They are grandfathered for OAuth (existing user before policy change), OR
+ * The system has an ENTERPRISE license (SAML is enterprise-only)
+ *
+ *
+ * @param user The user to check
+ * @return true if the user can use SAML
+ */
+ public boolean isSamlEligible(stirling.software.proprietary.security.model.User user) {
+ // Grandfathered users always have SAML access
+ if (user != null && user.isOauthGrandfathered()) {
+ log.debug("User {} is grandfathered for SAML", user.getUsername());
+ return true;
+ }
+
+ // Users can use SAML only with ENTERPRISE license
+ boolean hasEnterprise = hasEnterpriseLicense();
+ log.debug("SAML eligibility check: hasEnterpriseLicense={}", hasEnterprise);
+ return hasEnterprise;
}
/**
@@ -500,8 +526,7 @@ public class UserLicenseSettingsService {
}
/**
- * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
- * (OAuth/SAML).
+ * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SAML.
*
* @return true if ENTERPRISE license is active
*/
diff --git a/build.gradle b/build.gradle
index 1738c1ef72..2ae1355944 100644
--- a/build.gradle
+++ b/build.gradle
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
- version = '2.0.3'
+ version = '2.1.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 7ddc1e72f5..4b962de492 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -3036,6 +3036,91 @@ title = "Get Info on PDF"
header = "Get Info on PDF"
submit = "Get Info"
downloadJson = "Download JSON"
+processing = "Extracting information..."
+results = "Results"
+noResults = "Run the tool to generate a report."
+downloads = "Downloads"
+noneDetected = "None detected"
+indexTitle = "Index"
+
+[getPdfInfo.report]
+entryLabel = "Full information summary"
+shortTitle = "PDF Information"
+
+[getPdfInfo.sections]
+metadata = "Metadata"
+formFields = "Form Fields"
+basicInfo = "Basic Info"
+documentInfo = "Document Info"
+compliance = "Compliance"
+encryption = "Encryption"
+permissions = "Permissions"
+other = "Other"
+perPageInfo = "Per Page Info"
+tableOfContents = "Table of Contents"
+
+[getPdfInfo.other]
+attachments = "Attachments"
+embeddedFiles = "Embedded Files"
+javaScript = "JavaScript"
+layers = "Layers"
+structureTree = "StructureTree"
+xmp = "XMPMetadata"
+
+[getPdfInfo.perPage]
+size = "Size"
+annotations = "Annotations"
+images = "Images"
+links = "Links"
+fonts = "Fonts"
+xobjects = "XObject Counts"
+multimedia = "Multimedia"
+
+[getPdfInfo.summary]
+pages = "Pages"
+fileSize = "File Size"
+pdfVersion = "PDF Version"
+language = "Language"
+title = "PDF Summary"
+author = "Author"
+created = "Created"
+modified = "Modified"
+permsAll = "All Permissions Allowed"
+permsRestricted = "{{count}} restrictions"
+permsMixed = "Some permissions restricted"
+hasCompliance = "Has compliance standards"
+noCompliance = "No Compliance Standards"
+basic = "Basic Information"
+documentInfo = "Document Information"
+securityTitle = "Security Status"
+technical = "Technical"
+overviewTitle = "PDF Overview"
+
+[getPdfInfo.summary.security]
+encrypted = "Encrypted PDF - Password protection present"
+unencrypted = "Unencrypted PDF - No password protection"
+
+[getPdfInfo.summary.tech]
+images = "Images"
+fonts = "Fonts"
+formFields = "Form Fields"
+embeddedFiles = "Embedded Files"
+javaScript = "JavaScript"
+layers = "Layers"
+bookmarks = "Bookmarks"
+multimedia = "Multimedia"
+
+[getPdfInfo.summary.overview]
+untitled = "an untitled document"
+unknown = "Unknown Author"
+text = "This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}})."
+
+[getPdfInfo.error]
+partial = "Some files could not be processed."
+unexpected = "Unexpected error during extraction."
+
+[getPdfInfo.status]
+complete = "Extraction complete"
[extractPage]
tags = "extract"
@@ -3454,8 +3539,8 @@ signinTitle = "Please sign in"
ssoSignIn = "Login via Single Sign-on"
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create User Disabled"
oAuth2AdminBlockedUser = "Registration or logging in of non-registered users is currently blocked. Please contact the administrator."
-oAuth2RequiresLicense = "OAuth/SSO login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
-saml2RequiresLicense = "SAML login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
+oAuth2RequiresLicense = "OAuth/SSO login requires a Server or Enterprise license. Please contact the administrator to upgrade your plan."
+saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan."
maxUsersReached = "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats."
oauth2RequestNotFound = "Authorization request not found"
oauth2InvalidUserInfoResponse = "Invalid User Info Response"
@@ -5906,6 +5991,7 @@ earlyAccess = "Early Access"
reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
+saveChanges = "Save Changes"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@@ -5943,6 +6029,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty]
title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content."
+dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
+dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
[pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)"
diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx
index f6477c67aa..0fa31dd244 100644
--- a/frontend/src/core/components/layout/Workbench.tsx
+++ b/frontend/src/core/components/layout/Workbench.tsx
@@ -71,6 +71,20 @@ export default function Workbench() {
};
const renderMainContent = () => {
+ // Check for custom workbench views first
+ if (!isBaseWorkbench(currentView)) {
+ const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
+ if (customView) {
+ // PDF text editor handles its own empty state (shows dropzone when no document)
+ const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
+ if (handlesOwnEmptyState || activeFiles.length > 0) {
+ const CustomComponent = customView.component;
+ return ;
+ }
+ }
+ }
+
+ // For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) {
return (
view.workbenchId === currentView && view.data != null);
-
-
- if (customView) {
- const CustomComponent = customView.component;
- return ;
- }
- }
return ;
}
};
diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx
index cc7a8777c1..efa9a2a5d3 100644
--- a/frontend/src/core/components/shared/AllToolsNavButton.tsx
+++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
@@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC = ({
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
- const handleClick = () => {
+ const performNavigation = () => {
setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
};
+ const handleClick = () => {
+ if (hasUnsavedChanges) {
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
+ performNavigation();
+ };
+
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
+ if (hasUnsavedChanges) {
+ e.preventDefault();
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
handleUnlessSpecialClick(e, handleClick);
};
diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx
index faff074279..b8803f1760 100644
--- a/frontend/src/core/components/shared/NavigationWarningModal.tsx
+++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx
@@ -12,7 +12,7 @@ interface NavigationWarningModalProps {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
- const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
+ const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
@@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
};
const BUTTON_WIDTH = "10rem";
- if (!hasUnsavedChanges) {
+ // Only show modal if there are unsaved changes AND there's an actual pending navigation
+ // This prevents the modal from showing due to spurious state updates
+ if (!hasUnsavedChanges || !pendingNavigation) {
return null;
}
diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx
index 029a6d567e..28efd60cbc 100644
--- a/frontend/src/core/components/shared/QuickAccessBar.tsx
+++ b/frontend/src/core/components/shared/QuickAccessBar.tsx
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
@@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
@@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
};
// Helper function to render navigation buttons with URL support
- const renderNavButton = (config: ButtonConfig, index: number) => {
+ const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
@@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef((_, ref) => {
: null;
const handleClick = (e?: React.MouseEvent) => {
+ // If there are unsaved changes and this button should guard navigation, show warning modal
+ if (shouldGuardNavigation && hasUnsavedChanges) {
+ e?.preventDefault();
+ navigationActions.requestNavigation(() => {
+ config.onClick();
+ });
+ return;
+ }
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
@@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
- onClick: () => handleClick(),
+ onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
@@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{mainButtons.map((config, index) => (
- {renderNavButton(config, index)}
+ {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
))}
diff --git a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts
index f51f997c72..f9d4af7c4e 100644
--- a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts
+++ b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts
@@ -97,6 +97,7 @@ export const OAUTH2_PROVIDERS: Provider[] = [
icon: 'key-rounded',
type: 'oauth2',
scope: 'SSO',
+ businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'issuer',
@@ -141,6 +142,7 @@ export const GENERIC_OAUTH2_PROVIDER: Provider = {
icon: 'link-rounded',
type: 'oauth2',
scope: 'SSO',
+ businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'enabled',
@@ -262,8 +264,8 @@ export const SAML2_PROVIDER: Provider = {
name: 'SAML2',
icon: 'verified-user-rounded',
type: 'saml2',
- scope: 'SSO',
- businessTier: true,
+ scope: 'SSO (SAML)',
+ businessTier: true, // Enterprise tier - SAML only
fields: [
{
key: 'enabled',
diff --git a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
index 273c09d0e1..55b79b26ca 100644
--- a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
+++ b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
@@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import FitText from '@app/components/shared/FitText';
@@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC = ({ setActiveButton, tooltipPosition = 'right' }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
@@ -150,10 +153,16 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
- handleUnlessSpecialClick(e, () => {
+ const performNavigation = () => {
setActiveButton('tools');
handleBackToTools();
- });
+ };
+ if (hasUnsavedChanges) {
+ e.preventDefault();
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
+ handleUnlessSpecialClick(e, performNavigation);
}}
size={'lg'}
variant="subtle"
diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx
new file mode 100644
index 0000000000..ec5d3805e3
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx
@@ -0,0 +1,128 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import { Badge, Divider, Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type {
+ PdfInfoReportData,
+ PdfInfoReportEntry,
+ PdfInfoBackendData,
+ ParsedPdfSections,
+} from '@app/types/getPdfInfo';
+import '@app/components/tools/validateSignature/reportView/styles.css';
+import SummarySection from '@app/components/tools/getPdfInfo/sections/SummarySection';
+import KeyValueSection from '@app/components/tools/getPdfInfo/sections/KeyValueSection';
+import TableOfContentsSection from '@app/components/tools/getPdfInfo/sections/TableOfContentsSection';
+import OtherSection from '@app/components/tools/getPdfInfo/sections/OtherSection';
+import PerPageSection from '@app/components/tools/getPdfInfo/sections/PerPageSection';
+
+
+/** Valid section anchor IDs for navigation */
+const VALID_ANCHORS = new Set([
+ 'summary', 'metadata', 'formFields', 'basicInfo', 'documentInfo',
+ 'compliance', 'encryption', 'permissions', 'toc', 'other', 'perPage',
+]);
+
+interface GetPdfInfoReportViewProps {
+ data: PdfInfoReportData & { scrollTo?: string | null };
+}
+
+const GetPdfInfoReportView: React.FC = ({ data }) => {
+ const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const entry: PdfInfoReportEntry | null = data.entries[0] ?? null;
+
+ useEffect(() => {
+ if (!data.scrollTo || !VALID_ANCHORS.has(data.scrollTo)) return;
+ const anchor = data.scrollTo;
+ const container = containerRef.current;
+ const el = container?.querySelector(`#${anchor}`);
+ if (el && container) {
+ // Calculate scroll position with 4rem buffer from top
+ const bufferPx = parseFloat(getComputedStyle(document.documentElement).fontSize) * 4;
+ const elementTop = el.getBoundingClientRect().top;
+ const containerTop = container.getBoundingClientRect().top;
+ const currentScroll = container.scrollTop;
+ const targetScroll = currentScroll + (elementTop - containerTop) - bufferPx;
+
+ container.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' });
+
+ // Flash highlight the section
+ el.classList.remove('section-flash-highlight');
+ void el.offsetWidth; // Force reflow
+ el.classList.add('section-flash-highlight');
+ setTimeout(() => el.classList.remove('section-flash-highlight'), 1500);
+ }
+ }, [data.scrollTo]);
+
+ const sections = useMemo((): ParsedPdfSections => {
+ const raw: PdfInfoBackendData = entry?.data ?? {};
+ return {
+ metadata: raw.Metadata ?? null,
+ formFields: raw.FormFields ?? raw['Form Fields'] ?? null,
+ basicInfo: raw.BasicInfo ?? raw['Basic Info'] ?? null,
+ documentInfo: raw.DocumentInfo ?? raw['Document Info'] ?? null,
+ compliance: raw.Compliancy ?? raw.Compliance ?? null,
+ encryption: raw.Encryption ?? null,
+ permissions: raw.Permissions ?? null,
+ toc: raw['Bookmarks/Outline/TOC'] ?? raw['Table of Contents'] ?? null,
+ other: raw.Other ?? null,
+ perPage: raw.PerPageInfo ?? raw['Per Page Info'] ?? null,
+ summaryData: raw.SummaryData ?? null,
+ };
+ }, [entry]);
+
+ if (!entry) {
+ return (
+
+
+ No Data
+ Run the tool to generate the report.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {entry.fileName}
+ - {t('getPdfInfo.summary.title', 'PDF Summary')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default GetPdfInfoReportView;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx
new file mode 100644
index 0000000000..5ee89db4aa
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx
@@ -0,0 +1,79 @@
+import { useCallback, useMemo } from 'react';
+import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
+
+interface GetPdfInfoResultsProps {
+ operation: GetPdfInfoOperationHook;
+ isLoading: boolean;
+ errorMessage: string | null;
+}
+
+const findFileByExtension = (files: File[], extension: string) => {
+ return files.find((file) => file.name.toLowerCase().endsWith(extension));
+};
+
+const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoResultsProps) => {
+ const { t } = useTranslation();
+
+ const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
+ const selectedFile = useMemo(() => jsonFile ?? null, [jsonFile]);
+ const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
+
+ const handleDownload = useCallback((file: File) => {
+ const blobUrl = URL.createObjectURL(file);
+ const link = document.createElement('a');
+ link.href = blobUrl;
+ link.download = file.name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(blobUrl);
+ }, []);
+
+ if (isLoading && operation.results.length === 0) {
+ return (
+
+
+ {t('getPdfInfo.processing', 'Extracting information...')}
+
+ );
+ }
+
+ if (!isLoading && operation.results.length === 0) {
+ return (
+
+ {t('getPdfInfo.noResults', 'Run the tool to generate a report.')}
+
+ );
+ }
+
+ return (
+
+ {/* No background post-processing once JSON is ready */}
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+
+ {t('getPdfInfo.downloads', 'Downloads')}
+
+ selectedFile && handleDownload(selectedFile)}
+ disabled={!selectedFile}
+ fullWidth
+ >
+ {selectedDownloadLabel}
+
+
+
+ );
+};
+
+export default GetPdfInfoResults;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx
new file mode 100644
index 0000000000..a98a475688
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
+import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
+
+interface KeyValueSectionProps {
+ title: string;
+ anchorId: string;
+ obj?: Record | null;
+ emptyLabel?: string;
+}
+
+const KeyValueSection: React.FC = ({ title, anchorId, obj, emptyLabel }) => {
+ return (
+
+
+
+ );
+};
+
+export default KeyValueSection;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx
new file mode 100644
index 0000000000..e7eb8d8b3e
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { Accordion, Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { PdfOtherInfo } from '@app/types/getPdfInfo';
+import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
+import ScrollableCodeBlock from '@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock';
+import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
+
+interface OtherSectionProps {
+ anchorId: string;
+ other?: PdfOtherInfo | null;
+}
+
+const renderList = (arr: unknown[] | undefined, emptyText: string) => {
+ if (!arr || arr.length === 0) return {emptyText} ;
+ return (
+
+ {arr.map((item, idx) => (
+
+ {typeof item === 'string' ? item : JSON.stringify(item)}
+
+ ))}
+
+ );
+};
+
+const OtherSection: React.FC = ({ anchorId, other }) => {
+ const { t } = useTranslation();
+ const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
+
+ const structureTreeContent = Array.isArray(other?.StructureTree) && other.StructureTree.length > 0
+ ? JSON.stringify(other.StructureTree, null, 2)
+ : null;
+
+ return (
+
+
+
+ {t('getPdfInfo.other.attachments', 'Attachments')}
+ {renderList(other?.Attachments, noneDetected)}
+
+
+ {t('getPdfInfo.other.embeddedFiles', 'Embedded Files')}
+ {renderList(other?.EmbeddedFiles, noneDetected)}
+
+
+ {t('getPdfInfo.other.javaScript', 'JavaScript')}
+ {renderList(other?.JavaScript, noneDetected)}
+
+
+ {t('getPdfInfo.other.layers', 'Layers')}
+ {renderList(other?.Layers, noneDetected)}
+
+
+
+
+ {t('getPdfInfo.other.structureTree', 'StructureTree')}
+
+
+
+
+
+
+
+ {t('getPdfInfo.other.xmp', 'XMPMetadata')}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OtherSection;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx
new file mode 100644
index 0000000000..4fd257cce6
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import { Accordion, Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { PdfPerPageInfo, PdfPageInfo, PdfFontInfo } from '@app/types/getPdfInfo';
+import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
+import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
+import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
+
+interface PerPageSectionProps {
+ anchorId: string;
+ perPage?: PdfPerPageInfo | null;
+}
+
+const renderList = (arr: unknown[] | undefined, emptyText: string) => {
+ if (!arr || arr.length === 0) return {emptyText} ;
+ return (
+
+ {arr.map((item, idx) => (
+
+ {typeof item === 'string' ? item : JSON.stringify(item)}
+
+ ))}
+
+ );
+};
+
+const renderFontsList = (fonts: PdfFontInfo[] | undefined, emptyText: string) => {
+ if (!fonts || fonts.length === 0) return {emptyText} ;
+ return (
+
+ {fonts.map((font, idx) => (
+
+ {`${font.Name ?? 'Unknown'}${font.IsEmbedded ? ' (embedded)' : ''}`}
+
+ ))}
+
+ );
+};
+
+const PerPageSection: React.FC = ({ anchorId, perPage }) => {
+ const { t } = useTranslation();
+ const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
+
+ const hasPages = perPage && Object.keys(perPage).length > 0;
+
+ return (
+
+ {hasPages ? (
+
+ {Object.entries(perPage).map(([pageLabel, pageInfo]: [string, PdfPageInfo]) => (
+
+
+ {pageLabel}
+
+
+
+
+ {pageInfo?.Size && (
+
+ {t('getPdfInfo.perPage.size', 'Size')}
+
+
+ )}
+
+ {pageInfo?.Annotations && (
+
+ {t('getPdfInfo.perPage.annotations', 'Annotations')}
+
+
+ )}
+
+ {t('getPdfInfo.perPage.images', 'Images')}
+ {renderList(pageInfo?.Images, noneDetected)}
+
+
+ {t('getPdfInfo.perPage.links', 'Links')}
+ {renderList(pageInfo?.Links, noneDetected)}
+
+
+ {t('getPdfInfo.perPage.fonts', 'Fonts')}
+ {renderFontsList(pageInfo?.Fonts, noneDetected)}
+
+ {pageInfo?.XObjectCounts && (
+
+ {t('getPdfInfo.perPage.xobjects', 'XObject Counts')}
+
+
+ )}
+
+ {t('getPdfInfo.perPage.multimedia', 'Multimedia')}
+ {renderList(pageInfo?.Multimedia, noneDetected)}
+
+
+
+
+
+ ))}
+
+ ) : (
+ {noneDetected}
+ )}
+
+ );
+};
+
+export default PerPageSection;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx
new file mode 100644
index 0000000000..680f4b64dd
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx
@@ -0,0 +1,148 @@
+import React, { useMemo } from 'react';
+import { Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { ParsedPdfSections, PdfFontInfo } from '@app/types/getPdfInfo';
+import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
+import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
+
+interface SummarySectionProps {
+ sections: ParsedPdfSections;
+ hideSectionTitle?: boolean;
+}
+
+const SummarySection: React.FC = ({ sections, hideSectionTitle = false }) => {
+ const { t } = useTranslation();
+
+ const summaryBlocks = useMemo(() => {
+ const basic = sections.basicInfo ?? {};
+ const docInfo = sections.documentInfo ?? {};
+ const metadata = sections.metadata ?? {};
+ const encryption = sections.encryption ?? {};
+ const permissions = sections.permissions ?? {};
+ const summary = sections.summaryData ?? {};
+ const other = sections.other ?? {};
+ const perPage = sections.perPage ?? {};
+
+ const pages = basic['Number of pages'];
+ const fileSizeBytes = basic.FileSizeInBytes;
+ const pdfVersion = docInfo['PDF version'];
+ const language = basic.Language;
+
+ const basicInformation: Record = {
+ [t('getPdfInfo.summary.pages', 'Pages')]: pages,
+ [t('getPdfInfo.summary.fileSize', 'File Size')]: typeof fileSizeBytes === 'number' ? `${(fileSizeBytes / 1024).toFixed(2)} KB` : fileSizeBytes,
+ [t('getPdfInfo.summary.pdfVersion', 'PDF Version')]: pdfVersion,
+ [t('getPdfInfo.summary.language', 'Language')]: language,
+ };
+
+ const documentInformation: Record = {
+ [t('getPdfInfo.summary.title', 'Title')]: metadata.Title,
+ [t('getPdfInfo.summary.author', 'Author')]: metadata.Author,
+ [t('getPdfInfo.summary.created', 'Created')]: metadata.CreationDate,
+ [t('getPdfInfo.summary.modified', 'Modified')]: metadata.ModificationDate,
+ };
+
+ const securityStatusText = encryption.IsEncrypted
+ ? t('getPdfInfo.summary.security.encrypted', 'Encrypted PDF - Password protection present')
+ : t('getPdfInfo.summary.security.unencrypted', 'Unencrypted PDF - No password protection');
+
+ const restrictedCount = summary.restrictedPermissionsCount ?? 0;
+ const permissionsAllAllowed = Object.values(permissions).every((v) => v === 'Allowed');
+ const permSummary = permissionsAllAllowed
+ ? t('getPdfInfo.summary.permsAll', 'All Permissions Allowed')
+ : restrictedCount > 0
+ ? t('getPdfInfo.summary.permsRestricted', '{{count}} restrictions', { count: restrictedCount })
+ : t('getPdfInfo.summary.permsMixed', 'Some permissions restricted');
+
+ const complianceText = sections.compliance && Object.values(sections.compliance).some(Boolean)
+ ? t('getPdfInfo.summary.hasCompliance', 'Has compliance standards')
+ : t('getPdfInfo.summary.noCompliance', 'No Compliance Standards');
+
+ // Helper to get first page data
+ const firstPage = perPage['Page 1'];
+ const firstPageFonts: PdfFontInfo[] = firstPage?.Fonts ?? [];
+
+ const technical: Record = {
+ [t('getPdfInfo.summary.tech.images', 'Images')]: (() => {
+ const total = basic.TotalImages;
+ if (typeof total === 'number') return total === 0 ? 'None' : `${total}`;
+ return 'None';
+ })(),
+ [t('getPdfInfo.summary.tech.fonts', 'Fonts')]: (() => {
+ if (firstPageFonts.length === 0) return 'None';
+ const embedded = firstPageFonts.filter((f) => f.IsEmbedded).length;
+ return `${firstPageFonts.length} (${embedded} embedded)`;
+ })(),
+ [t('getPdfInfo.summary.tech.formFields', 'Form Fields')]: sections.formFields && Object.keys(sections.formFields).length > 0 ? Object.keys(sections.formFields).length : 'None',
+ [t('getPdfInfo.summary.tech.embeddedFiles', 'Embedded Files')]: other.EmbeddedFiles?.length ?? 'None',
+ [t('getPdfInfo.summary.tech.javaScript', 'JavaScript')]: other.JavaScript?.length ?? 'None',
+ [t('getPdfInfo.summary.tech.layers', 'Layers')]: other.Layers?.length ?? 'None',
+ [t('getPdfInfo.summary.tech.bookmarks', 'Bookmarks')]: sections.toc?.length ?? 'None',
+ [t('getPdfInfo.summary.tech.multimedia', 'Multimedia')]: firstPage?.Multimedia?.length ?? 'None',
+ };
+
+ const overview = (() => {
+ const tTitle = metadata.Title ? `"${metadata.Title}"` : t('getPdfInfo.summary.overview.untitled', 'an untitled document');
+ const author = metadata.Author || t('getPdfInfo.summary.overview.unknown', 'Unknown Author');
+ const pagesCount = typeof pages === 'number' ? pages : '?';
+ const version = pdfVersion ?? '?';
+ return t('getPdfInfo.summary.overview.text', 'This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}}).', {
+ pages: pagesCount,
+ title: tTitle,
+ author,
+ version,
+ });
+ })();
+
+ return {
+ basicInformation,
+ documentInformation,
+ securityStatusText,
+ permSummary,
+ complianceText,
+ technical,
+ overview,
+ };
+ }, [sections, t]);
+
+ const content = (
+
+
+ {t('getPdfInfo.summary.basic', 'Basic Information')}
+
+
+
+ {t('getPdfInfo.summary.documentInfo', 'Document Information')}
+
+
+
+ {t('getPdfInfo.summary.securityTitle', 'Security Status')}
+ {summaryBlocks.securityStatusText}
+ {summaryBlocks.permSummary}
+ {summaryBlocks.complianceText}
+
+
+ {t('getPdfInfo.summary.technical', 'Technical')}
+
+
+
+ {t('getPdfInfo.summary.overviewTitle', 'PDF Overview')}
+ {summaryBlocks.overview}
+
+
+ );
+
+ if (hideSectionTitle) {
+ return {content}
;
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default SummarySection;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx
new file mode 100644
index 0000000000..57e4ac2fed
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { PdfTocEntry } from '@app/types/getPdfInfo';
+import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
+
+interface TableOfContentsSectionProps {
+ anchorId: string;
+ tocArray: PdfTocEntry[];
+}
+
+const TableOfContentsSection: React.FC = ({ anchorId, tocArray }) => {
+ const { t } = useTranslation();
+ const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
+
+ return (
+
+ {!tocArray || tocArray.length === 0 ? (
+ {noneDetected}
+ ) : (
+
+ {tocArray.map((item, idx) => (
+
+ {typeof item === 'string' ? item : JSON.stringify(item)}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default TableOfContentsSection;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx
new file mode 100644
index 0000000000..e0cd809cc4
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Group, Stack, Text } from '@mantine/core';
+
+interface KeyValueListProps {
+ obj?: Record | null;
+ emptyLabel?: string;
+}
+
+const KeyValueList: React.FC = ({ obj, emptyLabel }) => {
+ if (!obj || Object.keys(obj).length === 0) {
+ return {emptyLabel ?? 'None detected'} ;
+ }
+ return (
+
+ {Object.entries(obj).map(([k, v]) => (
+
+ {k}
+
+ {v == null ? '' : String(v)}
+
+
+ ))}
+
+ );
+};
+
+export default KeyValueList;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx
new file mode 100644
index 0000000000..bf04264268
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { Code, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+interface ScrollableCodeBlockProps {
+ content: string | null | undefined;
+ maxHeight?: string;
+ emptyMessage?: string;
+}
+
+/**
+ * A reusable scrollable code block component with consistent styling.
+ * Used for displaying large text content like XMP metadata or structure trees.
+ */
+const ScrollableCodeBlock: React.FC = ({
+ content,
+ maxHeight = '400px',
+ emptyMessage,
+}) => {
+ const { t } = useTranslation();
+
+ if (!content) {
+ return (
+
+ {emptyMessage ?? t('getPdfInfo.noneDetected', 'None detected')}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default ScrollableCodeBlock;
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx
new file mode 100644
index 0000000000..0faa993f60
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Stack, Text, Divider } from '@mantine/core';
+
+interface SectionBlockProps {
+ title: string;
+ anchorId: string;
+ children: React.ReactNode;
+}
+
+const SectionBlock: React.FC = ({ title, anchorId, children }) => {
+ return (
+
+ {title}
+
+ {children}
+
+ );
+};
+
+export default SectionBlock;
+
+
diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts b/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts
new file mode 100644
index 0000000000..e974c98450
--- /dev/null
+++ b/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts
@@ -0,0 +1,14 @@
+import type { AccordionStylesNames } from '@mantine/core';
+import type { CSSProperties } from 'react';
+
+type AccordionStyles = Partial>;
+
+export const pdfInfoAccordionStyles: AccordionStyles = {
+ item: {
+ backgroundColor: 'var(--accordion-item-bg)',
+ },
+ control: {
+ backgroundColor: 'transparent',
+ },
+};
+
diff --git a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
index 0dbe8c50e1..4eeff845b3 100644
--- a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
+++ b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
@@ -21,6 +21,7 @@ import {
Title,
Tooltip,
} from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
@@ -32,9 +33,12 @@ import CloseIcon from '@mui/icons-material/Close';
import MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
+import UploadFileIcon from '@mui/icons-material/UploadFileOutlined';
+import SaveIcon from '@mui/icons-material/SaveOutlined';
import { Rnd } from 'react-rnd';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
+import { useFileContext } from '@app/contexts/FileContext';
import {
PdfTextEditorViewData,
PdfJsonFont,
@@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const { t } = useTranslation();
+ const { activeFiles } = useFileContext();
const [activeGroupId, setActiveGroupId] = useState(null);
const [editingGroupId, setEditingGroupId] = useState(null);
const [activeImageId, setActiveImageId] = useState(null);
@@ -375,6 +380,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
fileName,
errorMessage,
isGeneratingPdf,
+ isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -389,11 +395,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onReset,
onDownloadJson,
onGeneratePdf,
- onGeneratePdfForNavigation,
+ onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onMergeGroups,
onUngroupGroup,
+ onLoadFile,
} = data;
// Define derived variables immediately after props destructuring, before any hooks
@@ -1430,7 +1437,8 @@ const selectionToolbarPosition = useMemo(() => {
height: '100%',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
- alignItems: 'start',
+ gridTemplateRows: '1fr',
+ alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem',
}}
>
@@ -1486,6 +1494,17 @@ const selectionToolbarPosition = useMemo(() => {
>
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
+ }
+ onClick={onSaveToWorkbench}
+ loading={isSavingToWorkbench}
+ disabled={!hasDocument || !hasChanges || isConverting}
+ fullWidth
+ >
+ {t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
+
{fileName && (
@@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => {
)}
{!hasDocument && !isConverting && (
-
-
-
-
- {t('pdfTextEditor.empty.title', 'No document loaded')}
-
-
- {t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
-
-
-
+
+ {
+ if (files.length > 0) {
+ onLoadFile(files[0]);
+ }
+ }}
+ accept={['application/pdf', 'application/json']}
+ maxFiles={1}
+ style={{
+ width: '100%',
+ maxWidth: 480,
+ minHeight: 200,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: '2px dashed var(--mantine-color-gray-4)',
+ borderRadius: 'var(--mantine-radius-lg)',
+ cursor: 'pointer',
+ transition: 'border-color 150ms ease, background-color 150ms ease',
+ }}
+ >
+
+
+
+ {t('pdfTextEditor.empty.title', 'No document loaded')}
+
+
+ {activeFiles.length > 0
+ ? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse')
+ : t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')}
+
+
+
+
)}
{isConverting && (
@@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => {
)}
- {hasDocument && (
+ {hasDocument && !isConverting && (
{
{/* Navigation Warning Modal */}
);
diff --git a/frontend/src/core/components/tools/validateSignature/reportView/styles.css b/frontend/src/core/components/tools/validateSignature/reportView/styles.css
index b2d01f2242..27b1081d4c 100644
--- a/frontend/src/core/components/tools/validateSignature/reportView/styles.css
+++ b/frontend/src/core/components/tools/validateSignature/reportView/styles.css
@@ -44,15 +44,15 @@
.simulated-page {
width: min(820px, 100%);
min-height: 1040px;
- background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
- box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
+ background-color: var(--bg-raised) !important;
+ box-shadow: 0 12px 32px var(--shadow-color) !important;
border-radius: 12px !important;
padding: 48px 56px !important;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
- color: rgb(var(--pdf-light-simulated-page-text)) !important;
+ color: var(--text-primary) !important;
}
/* Container for the interactive report view */
@@ -67,12 +67,12 @@
/* Keep field blocks stable colors across themes */
.field-value {
- border: 1px solid rgb(var(--pdf-light-box-border)) !important;
- background-color: rgb(var(--pdf-light-box-bg)) !important;
+ border: 1px solid var(--border-default) !important;
+ background-color: var(--bg-raised) !important;
}
.field-container {
- color: rgb(var(--pdf-light-simulated-page-text)) !important;
+ color: var(--text-primary) !important;
}
/* Thumbnail preview styles */
@@ -103,3 +103,28 @@
color: rgb(var(--pdf-light-text-muted));
background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
}
+
+/* Flash highlight animation for section navigation */
+@keyframes section-flash {
+ 0% {
+ background-color: rgba(255, 235, 59, 0);
+ box-shadow: none;
+ }
+ 20% {
+ background-color: rgba(255, 235, 59, 0.35);
+ box-shadow: 0 0 20px rgba(255, 235, 59, 0.5);
+ }
+ 50% {
+ background-color: rgba(255, 235, 59, 0.25);
+ box-shadow: 0 0 15px rgba(255, 235, 59, 0.4);
+ }
+ 100% {
+ background-color: rgba(255, 235, 59, 0);
+ box-shadow: none;
+ }
+}
+
+.section-flash-highlight {
+ animation: section-flash 1.5s ease-out;
+ border-radius: 8px;
+}
diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx
index 500a6db5e1..c11649400a 100644
--- a/frontend/src/core/contexts/NavigationContext.tsx
+++ b/frontend/src/core/contexts/NavigationContext.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useContext, useReducer, useCallback } from 'react';
+import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
import { ToolId, isValidToolId } from '@app/types/toolId';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
@@ -110,8 +110,8 @@ export const NavigationProvider: React.FC<{
const { allTools: toolRegistry } = useToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
- const actions: NavigationContextActions = {
- setWorkbench: useCallback((workbench: WorkbenchType) => {
+ // Memoize individual callbacks
+ const setWorkbench = useCallback((workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
console.log('[NavigationContext] setWorkbench:', {
@@ -152,13 +152,13 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}
- }, [state.workbench, state.hasUnsavedChanges]),
+ }, [state.workbench, state.hasUnsavedChanges]);
- setSelectedTool: useCallback((toolId: ToolId | null) => {
+ const setSelectedTool = useCallback((toolId: ToolId | null) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
- }, []),
+ }, []);
- setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
+ const setToolAndWorkbench = useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
@@ -177,25 +177,25 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}
- }, [state.workbench, state.hasUnsavedChanges]),
+ }, [state.workbench, state.hasUnsavedChanges]);
- setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
+ const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
- }, []),
+ }, []);
- registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
+ const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => {
unsavedChangesCheckerRef.current = checker;
- }, []),
+ }, []);
- unregisterUnsavedChangesChecker: useCallback(() => {
+ const unregisterUnsavedChangesChecker = useCallback(() => {
unsavedChangesCheckerRef.current = null;
- }, []),
+ }, []);
- showNavigationWarning: useCallback((show: boolean) => {
+ const showNavigationWarning = useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
- }, []),
+ }, []);
- requestNavigation: useCallback((navigationFn: () => void) => {
+ const requestNavigation = useCallback((navigationFn: () => void) => {
if (!state.hasUnsavedChanges) {
navigationFn();
return;
@@ -203,9 +203,9 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
- }, [state.hasUnsavedChanges]),
+ }, [state.hasUnsavedChanges]);
- confirmNavigation: useCallback(() => {
+ const confirmNavigation = useCallback(() => {
console.log('[NavigationContext] confirmNavigation called', {
hasPendingNav: !!state.pendingNavigation,
currentWorkbench: state.workbench,
@@ -218,18 +218,18 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
console.log('[NavigationContext] confirmNavigation completed');
- }, [state.pendingNavigation, state.workbench, state.selectedTool]),
+ }, [state.pendingNavigation, state.workbench, state.selectedTool]);
- cancelNavigation: useCallback(() => {
+ const cancelNavigation = useCallback(() => {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
- }, []),
+ }, []);
- clearToolSelection: useCallback(() => {
+ const clearToolSelection = useCallback(() => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
- }, []),
+ }, []);
- handleToolSelect: useCallback((toolId: string) => {
+ const handleToolSelect = useCallback((toolId: string) => {
if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return;
@@ -245,11 +245,40 @@ export const NavigationProvider: React.FC<{
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
- // Validate toolId and convert to ToolId type
- const validToolId = isValidToolId(toolId) ? toolId : null;
- dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
- }, [toolRegistry])
- };
+ // Validate toolId and convert to ToolId type
+ const validToolId = isValidToolId(toolId) ? toolId : null;
+ dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
+ }, [toolRegistry]);
+
+ // Memoize the actions object to prevent unnecessary context updates
+ // This is critical to avoid infinite loops when effects depend on actions
+ const actions: NavigationContextActions = useMemo(() => ({
+ setWorkbench,
+ setSelectedTool,
+ setToolAndWorkbench,
+ setHasUnsavedChanges,
+ registerUnsavedChangesChecker,
+ unregisterUnsavedChangesChecker,
+ showNavigationWarning,
+ requestNavigation,
+ confirmNavigation,
+ cancelNavigation,
+ clearToolSelection,
+ handleToolSelect,
+ }), [
+ setWorkbench,
+ setSelectedTool,
+ setToolAndWorkbench,
+ setHasUnsavedChanges,
+ registerUnsavedChangesChecker,
+ unregisterUnsavedChangesChecker,
+ showNavigationWarning,
+ requestNavigation,
+ confirmNavigation,
+ cancelNavigation,
+ clearToolSelection,
+ handleToolSelect,
+ ]);
const stateValue: NavigationContextStateValue = {
workbench: state.workbench,
@@ -259,9 +288,10 @@ export const NavigationProvider: React.FC<{
showNavigationWarning: state.showNavigationWarning
};
- const actionsValue: NavigationContextActionsValue = {
+ // Also memoize the context value to prevent unnecessary re-renders
+ const actionsValue: NavigationContextActionsValue = useMemo(() => ({
actions
- };
+ }), [actions]);
return (
diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx
index 9717b7f68f..b2c6958018 100644
--- a/frontend/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx
@@ -224,11 +224,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return;
}
+ if (navigationState.pendingNavigation || navigationState.showNavigationWarning) {
+ return;
+ }
+
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
}
- }, [actions, customWorkbenchViews, navigationState.workbench]);
+ }, [actions, customWorkbenchViews, navigationState.workbench, navigationState.pendingNavigation, navigationState.showNavigationWarning]);
// Persisted via PreferencesContext; no direct localStorage writes needed here
diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx
index 5d0bcd622e..0844b94d1a 100644
--- a/frontend/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx
@@ -27,6 +27,7 @@ import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContra
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
import { getSynonyms } from "@app/utils/toolSynonyms";
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
+import GetPdfInfo from "@app/tools/GetPdfInfo";
import AddWatermark from "@app/tools/AddWatermark";
import AddStamp from "@app/tools/AddStamp";
import AddAttachments from "@app/tools/AddAttachments";
@@ -151,6 +152,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Proprietary tools (if any)
...proprietaryTools,
// Recommended Tools in order
+ pdfTextEditor: {
+ icon: ,
+ name: t("home.pdfTextEditor.title", "PDF Text Editor"),
+ component: PdfTextEditor,
+ description: t(
+ "home.pdfTextEditor.desc",
+ "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
+ ),
+ categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
+ subcategoryId: SubcategoryId.GENERAL,
+ maxFiles: 1,
+ endpoints: ["text-editor-pdf"],
+ synonyms: getSynonyms(t, "pdfTextEditor"),
+ supportsAutomate: false,
+ automationSettings: null,
+ versionStatus: "alpha",
+ },
multiTool: {
icon: ,
name: t("home.multiTool.title", "Multi-Tool"),
@@ -324,14 +342,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
getPdfInfo: {
icon: ,
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
- component: null,
+ component: GetPdfInfo,
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
endpoints: ["get-info-on-pdf"],
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
- automationSettings: null
+ automationSettings: null,
+ maxFiles: 1,
},
validateSignature: {
icon: ,
@@ -891,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
- pdfTextEditor: {
- icon: ,
- name: t("home.pdfTextEditor.title", "PDF Text Editor"),
- component: PdfTextEditor,
- description: t(
- "home.pdfTextEditor.desc",
- "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
- ),
- categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
- subcategoryId: SubcategoryId.GENERAL,
- maxFiles: 1,
- endpoints: ["text-editor-pdf"],
- synonyms: getSynonyms(t, "pdfTextEditor"),
- supportsAutomate: false,
- automationSettings: null,
- versionStatus: "alpha",
- },
};
const regularTools = {} as RegularToolRegistry;
diff --git a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts
new file mode 100644
index 0000000000..019968bcac
--- /dev/null
+++ b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import apiClient from '@app/services/apiClient';
+import { useFileContext } from '@app/contexts/file/fileHooks';
+import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
+import type { StirlingFile } from '@app/types/fileContext';
+import { extractErrorMessage } from '@app/utils/toolErrorHandler';
+import {
+ PdfInfoReportEntry,
+ INFO_JSON_FILENAME,
+} from '@app/types/getPdfInfo';
+import type { GetPdfInfoParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters';
+
+export interface GetPdfInfoOperationHook extends ToolOperationHook {
+ results: PdfInfoReportEntry[];
+}
+
+export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => {
+ const { t } = useTranslation();
+ const { selectors } = useFileContext();
+ const [isLoading, setIsLoading] = useState(false);
+ const [status, setStatus] = useState('');
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [downloadUrl, setDownloadUrl] = useState(null);
+ const [downloadFilename, setDownloadFilename] = useState('');
+ const [results, setResults] = useState([]);
+
+ const cancelRequested = useRef(false);
+ const previousUrl = useRef(null);
+
+ const cleanupDownloadUrl = useCallback(() => {
+ if (previousUrl.current) {
+ URL.revokeObjectURL(previousUrl.current);
+ previousUrl.current = null;
+ }
+ }, []);
+
+ const resetResults = useCallback(() => {
+ cancelRequested.current = false;
+ setResults([]);
+ setFiles([]);
+ cleanupDownloadUrl();
+ setDownloadUrl(null);
+ setDownloadFilename('');
+ setStatus('');
+ setErrorMessage(null);
+ }, [cleanupDownloadUrl]);
+
+ const clearError = useCallback(() => {
+ setErrorMessage(null);
+ }, []);
+
+ const executeOperation = useCallback(
+ async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => {
+ if (selectedFiles.length === 0) {
+ setErrorMessage(t('noFileSelected', 'No files selected'));
+ return;
+ }
+
+ cancelRequested.current = false;
+ setIsLoading(true);
+ setStatus(t('getPdfInfo.processing', 'Extracting information...'));
+ setErrorMessage(null);
+ setResults([]);
+ setFiles([]);
+ cleanupDownloadUrl();
+ setDownloadUrl(null);
+ setDownloadFilename('');
+
+ try {
+ const aggregated: PdfInfoReportEntry[] = [];
+ const generatedAt = Date.now();
+
+ for (const file of selectedFiles) {
+ if (cancelRequested.current) break;
+
+ const formData = new FormData();
+ formData.append('fileInput', file);
+
+ try {
+ const response = await apiClient.post('/api/v1/security/get-info-on-pdf', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+
+ const stub = selectors.getStirlingFileStub(file.fileId);
+ const entry: PdfInfoReportEntry = {
+ fileId: file.fileId,
+ fileName: file.name,
+ fileSize: file.size ?? null,
+ lastModified: file.lastModified ?? null,
+ thumbnailUrl: stub?.thumbnailUrl ?? null,
+ data: response.data ?? {},
+ error: null,
+ summaryGeneratedAt: generatedAt,
+ };
+ aggregated.push(entry);
+ } catch (error) {
+ const stub = selectors.getStirlingFileStub(file.fileId);
+ aggregated.push({
+ fileId: file.fileId,
+ fileName: file.name,
+ fileSize: file.size ?? null,
+ lastModified: file.lastModified ?? null,
+ thumbnailUrl: stub?.thumbnailUrl ?? null,
+ data: {},
+ error: extractErrorMessage(error),
+ summaryGeneratedAt: generatedAt,
+ });
+ }
+ }
+
+ if (!cancelRequested.current) {
+ setResults(aggregated);
+ if (aggregated.length > 0) {
+ // Build V1-compatible JSON: use backend payloads directly.
+ const payloads = aggregated
+ .filter((e) => !e.error)
+ .map((e) => e.data);
+ const content = payloads.length === 1 ? payloads[0] : payloads;
+ const json = JSON.stringify(content, null, 2);
+ const resultFile = new File([json], INFO_JSON_FILENAME, { type: 'application/json' });
+ setFiles([resultFile]);
+ }
+
+ const anyError = aggregated.some((item) => item.error);
+ if (anyError) {
+ setErrorMessage(t('getPdfInfo.error.partial', 'Some files could not be processed.'));
+ }
+ setStatus(t('getPdfInfo.status.complete', 'Extraction complete'));
+ }
+ } catch (e) {
+ console.error('[getPdfInfo] unexpected failure', e);
+ setErrorMessage(t('getPdfInfo.error.unexpected', 'Unexpected error during extraction.'));
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [cleanupDownloadUrl, selectors, t]
+ );
+
+ const cancelOperation = useCallback(() => {
+ if (isLoading) {
+ cancelRequested.current = true;
+ setIsLoading(false);
+ setStatus(t('operationCancelled', 'Operation cancelled'));
+ }
+ }, [isLoading, t]);
+
+ const undoOperation = useCallback(async () => {
+ resetResults();
+ }, [resetResults]);
+
+ useEffect(() => {
+ return () => {
+ cleanupDownloadUrl();
+ };
+ }, [cleanupDownloadUrl]);
+
+ return useMemo(
+ () => ({
+ files,
+ thumbnails: [],
+ isGeneratingThumbnails: false,
+ downloadUrl,
+ downloadFilename,
+ isLoading,
+ status,
+ errorMessage,
+ progress: null,
+ executeOperation,
+ resetResults,
+ clearError,
+ cancelOperation,
+ undoOperation,
+ results,
+ }),
+ [
+ cancelOperation,
+ clearError,
+ downloadFilename,
+ downloadUrl,
+ errorMessage,
+ executeOperation,
+ files,
+ isLoading,
+ resetResults,
+ results,
+ status,
+ ]
+ );
+};
+
+
diff --git a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts
new file mode 100644
index 0000000000..488484809a
--- /dev/null
+++ b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts
@@ -0,0 +1,19 @@
+import { BaseParameters } from '@app/types/parameters';
+import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
+
+export interface GetPdfInfoParameters extends BaseParameters {
+ // No parameters needed
+}
+
+export const defaultParameters: GetPdfInfoParameters = {};
+
+export type GetPdfInfoParametersHook = BaseParametersHook;
+
+export const useGetPdfInfoParameters = (): GetPdfInfoParametersHook => {
+ return useBaseParameters({
+ defaultParameters,
+ endpointName: 'get-info-on-pdf',
+ });
+};
+
+
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index b629e86a5c..c22a9624d9 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -59,17 +59,21 @@ export default function HomePage() {
const prevFileCountRef = useRef(activeFiles.length);
// Auto-switch to viewer when going from 0 to 1 file
+ // Skip this if PDF Text Editor is active - it handles its own empty state
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
if (prevCount === 0 && currentCount === 1) {
- actions.setWorkbench('viewer');
- setActiveFileIndex(0);
+ // PDF Text Editor handles its own empty state with a dropzone
+ if (selectedToolKey !== 'pdfTextEditor') {
+ actions.setWorkbench('viewer');
+ setActiveFileIndex(0);
+ }
}
prevFileCountRef.current = currentCount;
- }, [activeFiles.length, actions, setActiveFileIndex]);
+ }, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css
index 30991cf30a..8551735eda 100644
--- a/frontend/src/core/styles/theme.css
+++ b/frontend/src/core/styles/theme.css
@@ -256,6 +256,7 @@
--header-selected-bg: #1E88E5; /* light mode selected header matches dark */
--header-selected-fg: #FFFFFF;
--file-card-bg: #FFFFFF; /* file card background (light/dark paired) */
+ --accordion-item-bg: #E8EAED; /* accordion item background - more distinguishable */
/* shadows */
--drop-shadow-color: rgba(0, 0, 0, 0.08);
@@ -519,6 +520,7 @@
--header-selected-fg: #FFFFFF;
/* file card background (dark) */
--file-card-bg: #1F2329;
+ --accordion-item-bg: #373D45; /* accordion item background - more distinguishable */
/* shadows */
--drop-shadow-color: rgba(255, 255, 255, 0.08);
diff --git a/frontend/src/core/tools/GetPdfInfo.tsx b/frontend/src/core/tools/GetPdfInfo.tsx
new file mode 100644
index 0000000000..aa35fe16bf
--- /dev/null
+++ b/frontend/src/core/tools/GetPdfInfo.tsx
@@ -0,0 +1,188 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
+import LinkIcon from '@mui/icons-material/Link';
+import { Stack, Group, Divider, Text, UnstyledButton } from '@mantine/core';
+import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
+import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
+import { BaseToolProps, ToolComponent } from '@app/types/tool';
+import { useGetPdfInfoParameters, defaultParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters';
+import GetPdfInfoResults from '@app/components/tools/getPdfInfo/GetPdfInfoResults';
+import { useGetPdfInfoOperation, GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
+import GetPdfInfoReportView from '@app/components/tools/getPdfInfo/GetPdfInfoReportView';
+import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
+import type { PdfInfoReportData } from '@app/types/getPdfInfo';
+
+const CHAPTERS = [
+ { id: 'summary', labelKey: 'getPdfInfo.summary.title', fallback: 'PDF Summary' },
+ { id: 'metadata', labelKey: 'getPdfInfo.sections.metadata', fallback: 'Metadata' },
+ { id: 'formFields', labelKey: 'getPdfInfo.sections.formFields', fallback: 'Form Fields' },
+ { id: 'basicInfo', labelKey: 'getPdfInfo.sections.basicInfo', fallback: 'Basic Info' },
+ { id: 'documentInfo', labelKey: 'getPdfInfo.sections.documentInfo', fallback: 'Document Info' },
+ { id: 'compliance', labelKey: 'getPdfInfo.sections.compliance', fallback: 'Compliance' },
+ { id: 'encryption', labelKey: 'getPdfInfo.sections.encryption', fallback: 'Encryption' },
+ { id: 'permissions', labelKey: 'getPdfInfo.sections.permissions', fallback: 'Permissions' },
+ { id: 'toc', labelKey: 'getPdfInfo.sections.tableOfContents', fallback: 'Table of Contents' },
+ { id: 'other', labelKey: 'getPdfInfo.sections.other', fallback: 'Other' },
+ { id: 'perPage', labelKey: 'getPdfInfo.sections.perPageInfo', fallback: 'Per Page Info' },
+];
+
+const GetPdfInfo = (props: BaseToolProps) => {
+ const { t } = useTranslation();
+ const { actions: navigationActions } = useNavigationActions();
+ const navigationState = useNavigationState();
+ const {
+ registerCustomWorkbenchView,
+ unregisterCustomWorkbenchView,
+ setCustomWorkbenchViewData,
+ clearCustomWorkbenchViewData,
+ } = useToolWorkflow();
+
+ const REPORT_VIEW_ID = 'getPdfInfoReport';
+ const REPORT_WORKBENCH_ID = 'custom:getPdfInfoReport' as const;
+ const reportIcon = useMemo(() => , []);
+
+ const base = useBaseTool(
+ 'getPdfInfo',
+ useGetPdfInfoParameters,
+ useGetPdfInfoOperation,
+ props
+ );
+
+ const operation = base.operation as GetPdfInfoOperationHook;
+ const hasResults = operation.results.length > 0;
+ const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
+
+ useEffect(() => {
+ registerCustomWorkbenchView({
+ id: REPORT_VIEW_ID,
+ workbenchId: REPORT_WORKBENCH_ID,
+ label: t('getPdfInfo.report.shortTitle', 'PDF Information'),
+ icon: reportIcon,
+ component: GetPdfInfoReportView,
+ });
+
+ return () => {
+ clearCustomWorkbenchViewData(REPORT_VIEW_ID);
+ unregisterCustomWorkbenchView(REPORT_VIEW_ID);
+ };
+ }, [
+ clearCustomWorkbenchViewData,
+ registerCustomWorkbenchView,
+ reportIcon,
+ t,
+ unregisterCustomWorkbenchView,
+ ]);
+
+ const reportData = useMemo(() => {
+ if (operation.results.length === 0) return null;
+ const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
+ return {
+ generatedAt,
+ entries: operation.results,
+ };
+ }, [operation.results]);
+
+ const lastReportGeneratedAtRef = useRef(null);
+ useEffect(() => {
+ if (reportData) {
+ setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
+ const generatedAt = reportData.generatedAt ?? null;
+ const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
+ if (isNewReport) {
+ lastReportGeneratedAtRef.current = generatedAt;
+ if (navigationState.selectedTool === 'getPdfInfo' && navigationState.workbench !== REPORT_WORKBENCH_ID) {
+ navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
+ }
+ }
+ } else {
+ clearCustomWorkbenchViewData(REPORT_VIEW_ID);
+ lastReportGeneratedAtRef.current = null;
+ }
+ }, [
+ clearCustomWorkbenchViewData,
+ navigationActions,
+ navigationState.selectedTool,
+ navigationState.workbench,
+ reportData,
+ setCustomWorkbenchViewData,
+ ]);
+
+ return createToolFlow({
+ files: {
+ selectedFiles: base.selectedFiles,
+ isCollapsed: hasResults,
+ },
+ steps: [
+ {
+ title: t('getPdfInfo.indexTitle', 'Index'),
+ isVisible: Boolean(reportData),
+ isCollapsed: false,
+ content: (
+
+ {CHAPTERS.map((c, idx) => (
+
+ {
+ if (!reportData) return;
+ setCustomWorkbenchViewData(REPORT_VIEW_ID, { ...reportData, scrollTo: c.id });
+ if (navigationState.workbench !== REPORT_WORKBENCH_ID) {
+ navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
+ }
+ }}
+ style={{ width: '100%', textAlign: 'left', padding: '8px 4px' }}
+ >
+
+
+
+ {t(c.labelKey, c.fallback)}
+
+
+
+ {idx < CHAPTERS.length - 1 && }
+
+ ))}
+
+ ),
+ },
+ {
+ title: t('getPdfInfo.results', 'Results'),
+ isVisible: showResultsStep,
+ isCollapsed: false,
+ content: (
+
+ ),
+ },
+ ],
+ executeButton: {
+ text: t('getPdfInfo.submit', 'Generate'),
+ loadingText: t('loading', 'Loading...'),
+ onClick: base.handleExecute,
+ disabled:
+ !base.params.validateParameters() ||
+ !base.hasFiles ||
+ base.operation.isLoading ||
+ !base.endpointEnabled,
+ isVisible: true,
+ },
+ review: {
+ isVisible: false,
+ operation: base.operation,
+ title: t('getPdfInfo.results', 'Results'),
+ onUndo: base.handleUndo,
+ },
+ });
+};
+
+const GetPdfInfoTool = GetPdfInfo as ToolComponent;
+GetPdfInfoTool.tool = () => useGetPdfInfoOperation;
+GetPdfInfoTool.getDefaultParameters = () => ({ ...defaultParameters });
+
+export default GetPdfInfoTool;
+
+
diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
index 571f3be09a..533dc644b3 100644
--- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
+++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
-import { useFileSelection } from '@app/contexts/FileContext';
+import { useFileSelection, useFileManagement, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
+import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
+import { getDefaultWorkbench } from '@app/types/workbench';
import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants';
import apiClient from '@app/services/apiClient';
import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils';
@@ -208,7 +210,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
} = useToolWorkflow();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
- const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions;
+ const { addFiles } = useFileManagement();
+ const { consumeFiles, selectors } = useFileContext();
const [loadedDocument, setLoadedDocument] = useState(null);
const [groupsByPage, setGroupsByPage] = useState([]);
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const [fileName, setFileName] = useState('');
const [errorMessage, setErrorMessage] = useState(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
+ const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false);
+ const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [conversionProgress, setConversionProgress] = useState(null);
const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalGroupsRef = useRef([]);
const imagesByPageRef = useRef([]);
const autoLoadKeyRef = useRef(null);
+ const sourceFileIdRef = useRef(null);
const loadRequestIdRef = useRef(0);
const latestPdfRequestIdRef = useRef(null);
const loadedDocumentRef = useRef(null);
@@ -279,6 +285,23 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
);
const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]);
const hasDocument = loadedDocument !== null;
+
+ // Sync hasChanges to navigation context so navigation guards can block
+ useEffect(() => {
+ navigationActions.setHasUnsavedChanges(hasChanges);
+ return () => {
+ navigationActions.setHasUnsavedChanges(false);
+ };
+ }, [hasChanges, navigationActions]);
+
+ // Navigate to files view AFTER the unsaved changes state is properly cleared
+ useEffect(() => {
+ if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) {
+ setShouldNavigateAfterSave(false);
+ navigationActions.setToolAndWorkbench(null, getDefaultWorkbench());
+ }
+ }, [shouldNavigateAfterSave, navigationState.hasUnsavedChanges, navigationActions]);
+
const viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]);
const { selectedFiles } = useFileSelection();
@@ -720,6 +743,21 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
[groupingMode, resetToDocument, t],
);
+ // Wrapper for loading files from the dropzone - adds to workbench first
+ const handleLoadFileFromDropzone = useCallback(
+ async (file: File) => {
+ // Add the file to the workbench so it appears in the file list
+ const addedFiles = await addFiles([file]);
+ // Capture the file ID for save-to-workbench functionality
+ if (addedFiles.length > 0 && addedFiles[0].fileId) {
+ sourceFileIdRef.current = addedFiles[0].fileId;
+ }
+ // Then load it into the editor
+ void handleLoadFile(file);
+ },
+ [addFiles, handleLoadFile],
+ );
+
const handleSelectPage = useCallback((pageIndex: number) => {
setSelectedPage(pageIndex);
// Trigger lazy loading for images on the selected page
@@ -1122,6 +1160,229 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
t,
]);
+ // Save changes to workbench (replaces the original file with edited version)
+ const handleSaveToWorkbench = useCallback(async () => {
+ setIsSavingToWorkbench(true);
+
+ try {
+ if (!sourceFileIdRef.current) {
+ console.warn('[PdfTextEditor] No source file ID available for save to workbench');
+ // Fall back to generating PDF download if no source file
+ await handleGeneratePdf(true);
+ return;
+ }
+
+ const parentStub = selectors.getStirlingFileStub(sourceFileIdRef.current as any);
+ if (!parentStub) {
+ console.warn('[PdfTextEditor] Could not find parent stub for save to workbench');
+ await handleGeneratePdf(true);
+ return;
+ }
+
+ const ensureImagesForPages = async (pageIndices: number[]) => {
+ const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0);
+ if (uniqueIndices.length === 0) {
+ return;
+ }
+
+ for (const index of uniqueIndices) {
+ if (!loadedImagePagesRef.current.has(index)) {
+ await loadImagesForPage(index);
+ }
+ }
+
+ const maxWaitTime = 15000;
+ const pollInterval = 150;
+ const startWait = Date.now();
+ while (Date.now() - startWait < maxWaitTime) {
+ const allLoaded = uniqueIndices.every(
+ (index) =>
+ loadedImagePagesRef.current.has(index) &&
+ imagesByPageRef.current[index] !== undefined,
+ );
+ const anyLoading = uniqueIndices.some((index) =>
+ loadingImagePagesRef.current.has(index),
+ );
+ if (allLoaded && !anyLoading) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+
+ const missing = uniqueIndices.filter(
+ (index) => !loadedImagePagesRef.current.has(index),
+ );
+ if (missing.length > 0) {
+ throw new Error(
+ `Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`,
+ );
+ }
+ };
+
+ const currentDoc = loadedDocumentRef.current;
+ const totalPages = currentDoc?.pages?.length ?? 0;
+ const currentDirtyPages = getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current);
+ const dirtyPageIndices = currentDirtyPages
+ .map((isDirty, index) => (isDirty ? index : -1))
+ .filter((index) => index >= 0);
+
+ let pdfBlob: Blob;
+ let downloadName: string;
+
+ const canUseIncremental =
+ isLazyMode &&
+ cachedJobId &&
+ dirtyPageIndices.length > 0 &&
+ dirtyPageIndices.length < totalPages;
+
+ if (canUseIncremental) {
+ await ensureImagesForPages(dirtyPageIndices);
+
+ try {
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const dirtyPageSet = new Set(dirtyPageIndices);
+ const partialPages =
+ document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
+
+ const partialDocument: PdfJsonDocument = {
+ metadata: document.metadata,
+ xmpMetadata: document.xmpMetadata,
+ fonts: document.fonts,
+ lazyImages: true,
+ pages: partialPages,
+ };
+
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ const expectedName = `${baseName || 'document'}.pdf`;
+ const response = await apiClient.post(
+ `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
+ partialDocument,
+ {
+ responseType: 'blob',
+ },
+ );
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ downloadName = detectedName || expectedName;
+ pdfBlob = response.data;
+ } catch (incrementalError) {
+ console.warn(
+ '[handleSaveToWorkbench] Incremental export failed, falling back to full export',
+ incrementalError,
+ );
+ // Fall through to full export
+ if (isLazyMode && totalPages > 0) {
+ const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
+ await ensureImagesForPages(allPageIndices);
+ }
+
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const serialized = JSON.stringify(document);
+ const jsonFile = new File([serialized], filename, { type: 'application/json' });
+
+ const formData = new FormData();
+ formData.append('fileInput', jsonFile);
+ const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
+ responseType: 'blob',
+ });
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ downloadName = detectedName || `${baseName || 'document'}.pdf`;
+ pdfBlob = response.data;
+ }
+ } else {
+ if (isLazyMode && totalPages > 0) {
+ const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
+ await ensureImagesForPages(allPageIndices);
+ }
+
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const serialized = JSON.stringify(document);
+ const jsonFile = new File([serialized], filename, { type: 'application/json' });
+
+ const formData = new FormData();
+ formData.append('fileInput', jsonFile);
+ const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
+ responseType: 'blob',
+ });
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ downloadName = detectedName || `${baseName || 'document'}.pdf`;
+ pdfBlob = response.data;
+ }
+
+ // Create the new PDF file
+ const pdfFile = new File([pdfBlob], downloadName, { type: 'application/pdf' });
+
+ // Create StirlingFile and stub for the output
+ const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
+ [pdfFile],
+ parentStub,
+ 'pdfTextEditor',
+ );
+
+ // Replace the original file with the edited version
+ await consumeFiles([sourceFileIdRef.current as any], stirlingFiles, stubs);
+
+ // Update the source file ID to point to the new file
+ sourceFileIdRef.current = stubs[0].id;
+
+ // Clear the unsaved changes flag - this will trigger the useEffect to navigate
+ // once React has processed the state update
+ navigationActions.setHasUnsavedChanges(false);
+ setErrorMessage(null);
+
+ // Set flag to trigger navigation after state update is processed
+ setShouldNavigateAfterSave(true);
+ } catch (error: any) {
+ console.error('Failed to save to workbench', error);
+ const message =
+ error?.response?.data ||
+ error?.message ||
+ t('pdfTextEditor.errors.pdfConversion', 'Unable to save changes to workbench.');
+ const msgString = typeof message === 'string' ? message : String(message);
+ setErrorMessage(msgString);
+ if (onError) {
+ onError(msgString);
+ }
+ } finally {
+ setIsSavingToWorkbench(false);
+ }
+ }, [
+ buildPayload,
+ cachedJobId,
+ consumeFiles,
+ groupsByPage,
+ handleGeneratePdf,
+ imagesByPage,
+ isLazyMode,
+ loadImagesForPage,
+ navigationActions,
+ onError,
+ selectors,
+ t,
+ ]);
+
const requestPagePreview = useCallback(
async (pageIndex: number, scale: number) => {
if (!hasVectorPreview || !pdfDocumentRef.current) {
@@ -1260,6 +1521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
fileName,
errorMessage,
isGeneratingPdf,
+ isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -1278,15 +1540,19 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
// Generate PDF without triggering tool completion
await handleGeneratePdf(true);
},
+ onSaveToWorkbench: handleSaveToWorkbench,
onForceSingleTextElementChange: setForceSingleTextElement,
onGroupingModeChange: setGroupingMode,
onMergeGroups: handleMergeGroups,
onUngroupGroup: handleUngroupGroup,
+ onLoadFile: handleLoadFileFromDropzone,
}), [
handleMergeGroups,
handleUngroupGroup,
handleImageTransform,
+ handleSaveToWorkbench,
imagesByPage,
+ isSavingToWorkbench,
pagePreviews,
dirtyPages,
errorMessage,
@@ -1311,6 +1577,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
groupingMode,
requestPagePreview,
setForceSingleTextElement,
+ handleLoadFileFromDropzone,
]);
const latestViewDataRef = useRef(viewData);
@@ -1326,6 +1593,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
useEffect(() => {
if (selectedFiles.length === 0) {
autoLoadKeyRef.current = null;
+ sourceFileIdRef.current = null;
return;
}
@@ -1344,6 +1612,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}
autoLoadKeyRef.current = fileKey;
+ // Capture the source file ID for save-to-workbench functionality
+ sourceFileIdRef.current = (file as any).fileId ?? null;
void handleLoadFile(file);
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]);
@@ -1398,27 +1668,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
// The workbench should be set when the tool is selected via proper channels
// (tool registry, tool picker, etc.) - not forced here
- // Keep hasChanges in a ref for the checker to access
- const hasChangesRef = useRef(hasChanges);
- useEffect(() => {
- hasChangesRef.current = hasChanges;
- console.log('[PdfTextEditor] hasChanges updated to:', hasChanges);
- }, [hasChanges]);
-
- // Register unsaved changes checker for navigation guard
- useEffect(() => {
- const checker = () => {
- console.log('[PdfTextEditor] Checking unsaved changes:', hasChangesRef.current);
- return hasChangesRef.current;
- };
- registerUnsavedChangesChecker(checker);
- console.log('[PdfTextEditor] Registered unsaved changes checker');
- return () => {
- console.log('[PdfTextEditor] Unregistered unsaved changes checker');
- unregisterUnsavedChangesChecker();
- };
- }, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
-
const lastSentViewDataRef = useRef(null);
useEffect(() => {
diff --git a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
index 8439bb4c18..3dd45a4657 100644
--- a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
+++ b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
onDownloadJson: () => void;
onGeneratePdf: () => void;
onGeneratePdfForNavigation: () => Promise;
+ onSaveToWorkbench: () => Promise;
+ isSavingToWorkbench: boolean;
onForceSingleTextElementChange: (value: boolean) => void;
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
+ onLoadFile: (file: File) => void;
}
diff --git a/frontend/src/core/types/getPdfInfo.ts b/frontend/src/core/types/getPdfInfo.ts
new file mode 100644
index 0000000000..f489cd99ae
--- /dev/null
+++ b/frontend/src/core/types/getPdfInfo.ts
@@ -0,0 +1,273 @@
+/** Metadata section from PDF */
+export interface PdfMetadata {
+ Title?: string | null;
+ Author?: string | null;
+ Subject?: string | null;
+ Keywords?: string | null;
+ Creator?: string | null;
+ Producer?: string | null;
+ CreationDate?: string | null;
+ ModificationDate?: string | null;
+ [key: string]: unknown;
+}
+
+/** Basic info section */
+export interface PdfBasicInfo {
+ FileSizeInBytes?: number;
+ WordCount?: number;
+ ParagraphCount?: number;
+ CharacterCount?: number;
+ Compression?: boolean;
+ CompressionType?: string;
+ Language?: string | null;
+ 'Number of pages'?: number;
+ TotalImages?: number;
+ [key: string]: unknown;
+}
+
+/** Document info section */
+export interface PdfDocumentInfo {
+ 'PDF version'?: string;
+ Trapped?: string | null;
+ 'Page Mode'?: string;
+ [key: string]: unknown;
+}
+
+/** Encryption section */
+export interface PdfEncryption {
+ IsEncrypted?: boolean;
+ EncryptionAlgorithm?: string;
+ KeyLength?: number;
+ [key: string]: unknown;
+}
+
+/** Permissions section - values are "Allowed" or "Not Allowed" */
+export interface PdfPermissions {
+ 'Document Assembly'?: 'Allowed' | 'Not Allowed';
+ 'Extracting Content'?: 'Allowed' | 'Not Allowed';
+ 'Extracting for accessibility'?: 'Allowed' | 'Not Allowed';
+ 'Form Filling'?: 'Allowed' | 'Not Allowed';
+ 'Modifying'?: 'Allowed' | 'Not Allowed';
+ 'Modifying annotations'?: 'Allowed' | 'Not Allowed';
+ 'Printing'?: 'Allowed' | 'Not Allowed';
+ [key: string]: 'Allowed' | 'Not Allowed' | undefined;
+}
+
+/** Compliance section */
+export interface PdfCompliance {
+ 'IsPDF/ACompliant'?: boolean;
+ 'PDF/AConformanceLevel'?: string;
+ 'IsPDF/AValidated'?: boolean;
+ 'IsPDF/XCompliant'?: boolean;
+ 'IsPDF/ECompliant'?: boolean;
+ 'IsPDF/VTCompliant'?: boolean;
+ 'IsPDF/UACompliant'?: boolean;
+ 'IsPDF/BCompliant'?: boolean;
+ 'IsPDF/SECCompliant'?: boolean;
+ [key: string]: unknown;
+}
+
+/** Font info within a page */
+export interface PdfFontInfo {
+ Name?: string;
+ IsEmbedded?: boolean;
+ Subtype?: string;
+ ItalicAngle?: number;
+ IsItalic?: boolean;
+ IsBold?: boolean;
+ IsFixedPitch?: boolean;
+ IsSerif?: boolean;
+ IsSymbolic?: boolean;
+ IsScript?: boolean;
+ IsNonsymbolic?: boolean;
+ FontFamily?: string;
+ FontWeight?: number;
+ Count?: number;
+}
+
+/** Image info within a page */
+export interface PdfImageInfo {
+ Width?: number;
+ Height?: number;
+ Name?: string;
+ ColorSpace?: string;
+}
+
+/** Link info within a page */
+export interface PdfLinkInfo {
+ URI?: string;
+}
+
+/** Annotations info within a page */
+export interface PdfAnnotationsInfo {
+ AnnotationsCount?: number;
+ SubtypeCount?: number;
+ ContentsCount?: number;
+ [key: string]: unknown;
+}
+
+/** Size/dimensions info within a page */
+export interface PdfSizeInfo {
+ 'Width (px)'?: string;
+ 'Height (px)'?: string;
+ 'Width (in)'?: string;
+ 'Height (in)'?: string;
+ 'Width (cm)'?: string;
+ 'Height (cm)'?: string;
+ 'Standard Page'?: string;
+ [key: string]: unknown;
+}
+
+/** XObject counts within a page */
+export interface PdfXObjectCounts {
+ Image?: number;
+ Form?: number;
+ Other?: number;
+ [key: string]: unknown;
+}
+
+/** ICC Profile info */
+export interface PdfICCProfile {
+ 'ICC Profile Length'?: number;
+}
+
+/** Page-level information */
+export interface PdfPageInfo {
+ Size?: PdfSizeInfo;
+ Rotation?: number;
+ 'Page Orientation'?: string;
+ MediaBox?: string;
+ CropBox?: string;
+ BleedBox?: string;
+ TrimBox?: string;
+ ArtBox?: string;
+ 'Text Characters Count'?: number;
+ Annotations?: PdfAnnotationsInfo;
+ Images?: PdfImageInfo[];
+ Links?: PdfLinkInfo[];
+ Fonts?: PdfFontInfo[];
+ 'Color Spaces & ICC Profiles'?: PdfICCProfile[];
+ XObjectCounts?: PdfXObjectCounts;
+ Multimedia?: Record[];
+}
+
+/** Per-page info section (keyed by "Page 1", "Page 2", etc.) */
+export interface PdfPerPageInfo {
+ [pageLabel: string]: PdfPageInfo;
+}
+
+/** Embedded file info */
+export interface PdfEmbeddedFileInfo {
+ Name?: string;
+ FileSize?: number;
+}
+
+/** Attachment info */
+export interface PdfAttachmentInfo {
+ Name?: string;
+ Description?: string;
+}
+
+/** JavaScript info */
+export interface PdfJavaScriptInfo {
+ 'JS Name'?: string;
+ 'JS Script Length'?: number;
+}
+
+/** Layer info */
+export interface PdfLayerInfo {
+ Name?: string;
+}
+
+/** Structure tree element */
+export interface PdfStructureTreeElement {
+ Type?: string;
+ Content?: string;
+ Children?: PdfStructureTreeElement[];
+}
+
+/** Other section with miscellaneous data */
+export interface PdfOtherInfo {
+ Attachments?: PdfAttachmentInfo[];
+ EmbeddedFiles?: PdfEmbeddedFileInfo[];
+ JavaScript?: PdfJavaScriptInfo[];
+ Layers?: PdfLayerInfo[];
+ StructureTree?: PdfStructureTreeElement[];
+ 'Bookmarks/Outline/TOC'?: PdfTocEntry[];
+ XMPMetadata?: string | null;
+}
+
+/** Table of contents bookmark entry */
+export interface PdfTocEntry {
+ Title?: string;
+ [key: string]: unknown;
+}
+
+/** Summary data section */
+export interface PdfSummaryData {
+ encrypted?: boolean;
+ restrictedPermissions?: string[];
+ restrictedPermissionsCount?: number;
+ standardCompliance?: string;
+ standardPurpose?: string;
+ standardValidationPassed?: boolean;
+}
+
+/** Form fields section */
+export type PdfFormFields = Record;
+
+/** Parsed sections with normalized keys for frontend use */
+export interface ParsedPdfSections {
+ metadata?: PdfMetadata | null;
+ formFields?: PdfFormFields | null;
+ basicInfo?: PdfBasicInfo | null;
+ documentInfo?: PdfDocumentInfo | null;
+ compliance?: PdfCompliance | null;
+ encryption?: PdfEncryption | null;
+ permissions?: PdfPermissions | null;
+ toc?: PdfTocEntry[] | null;
+ other?: PdfOtherInfo | null;
+ perPage?: PdfPerPageInfo | null;
+ summaryData?: PdfSummaryData | null;
+}
+
+/** Raw backend response structure */
+export interface PdfInfoBackendData {
+ Metadata?: PdfMetadata;
+ FormFields?: PdfFormFields;
+ BasicInfo?: PdfBasicInfo;
+ DocumentInfo?: PdfDocumentInfo;
+ Compliancy?: PdfCompliance;
+ Encryption?: PdfEncryption;
+ Permissions?: PdfPermissions;
+ Other?: PdfOtherInfo;
+ PerPageInfo?: PdfPerPageInfo;
+ SummaryData?: PdfSummaryData;
+ // Legacy/alternative keys for backwards compatibility
+ 'Form Fields'?: PdfFormFields;
+ 'Basic Info'?: PdfBasicInfo;
+ 'Document Info'?: PdfDocumentInfo;
+ Compliance?: PdfCompliance;
+ 'Bookmarks/Outline/TOC'?: PdfTocEntry[];
+ 'Table of Contents'?: PdfTocEntry[];
+ 'Per Page Info'?: PdfPerPageInfo;
+}
+
+export interface PdfInfoReportEntry {
+ fileId: string;
+ fileName: string;
+ fileSize: number | null;
+ lastModified: number | null;
+ thumbnailUrl?: string | null;
+ data: PdfInfoBackendData;
+ error: string | null;
+ summaryGeneratedAt?: number;
+}
+
+export interface PdfInfoReportData {
+ generatedAt: number;
+ entries: PdfInfoReportEntry[];
+}
+
+export const INFO_JSON_FILENAME = 'response.json';
+export const INFO_PDF_FILENAME = 'pdf-information-report.pdf';
diff --git a/frontend/src/proprietary/constants/planConstants.ts b/frontend/src/proprietary/constants/planConstants.ts
index ab14a2c550..f7073d25cf 100644
--- a/frontend/src/proprietary/constants/planConstants.ts
+++ b/frontend/src/proprietary/constants/planConstants.ts
@@ -19,6 +19,7 @@ export const PLAN_FEATURES = {
{ name: 'Editing text in pdfs', included: false },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
+ { name: 'SAML', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
@@ -37,7 +38,8 @@ export const PLAN_FEATURES = {
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: false },
- { name: 'SSO', included: false },
+ { name: 'SSO', included: true },
+ { name: 'SAML', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
@@ -57,6 +59,7 @@ export const PLAN_FEATURES = {
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: true },
{ name: 'SSO', included: true },
+ { name: 'SAML', included: true },
{ name: 'Auditing', included: true },
{ name: 'Usage tracking', included: true },
{ name: 'Prometheus Support', included: true },
@@ -74,6 +77,7 @@ export const PLAN_HIGHLIGHTS = {
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
+ 'SSO (OAuth2/OIDC)',
'Editing text in PDFs',
'Cancel anytime'
],
@@ -81,17 +85,18 @@ export const PLAN_HIGHLIGHTS = {
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
+ 'SSO (OAuth2/OIDC)',
'Editing text in PDFs',
'Save with annual billing'
],
ENTERPRISE_MONTHLY: [
- 'Enterprise features (SSO, Auditing)',
+ 'Enterprise features (SAML, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Per-seat licensing'
],
ENTERPRISE_YEARLY: [
- 'Enterprise features (SSO, Auditing)',
+ 'Enterprise features (SAML, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Save with annual billing'