Merge branch 'main' into newTranslations

This commit is contained in:
Anthony Stirling
2025-12-03 23:24:06 +00:00
committed by GitHub
35 changed files with 2071 additions and 138 deletions
@@ -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;
@@ -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.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SSO is enterprise-only)
* <li>The system has a paid license (SERVER or ENTERPRISE)
* </ul>
*
* @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.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
* </ul>
*
* @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
*/
+1 -1
View File
@@ -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'
+90 -2
View File
@@ -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)"
@@ -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 <CustomComponent data={customView.data} />;
}
}
}
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) {
return (
<LandingPage
@@ -143,15 +157,6 @@ export default function Workbench() {
);
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />;
}
};
@@ -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<AllToolsNavButtonProps> = ({
}) => {
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);
};
@@ -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;
}
@@ -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<HTMLDivElement>((_, 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<HTMLDivElement>((_, 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<HTMLDivElement>((_, 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<HTMLDivElement>((_, 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<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center">
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
{renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
</React.Fragment>
))}
</Stack>
@@ -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',
@@ -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<ActiveToolButtonProps> = ({ 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<ActiveToolButtonProps> = ({ 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"
@@ -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<GetPdfInfoReportViewProps> = ({ data }) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(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<HTMLElement>(`#${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 (
<div className="report-container">
<Stack gap="md" align="center">
<Badge color="gray" variant="light">No Data</Badge>
<Text size="sm" c="dimmed">Run the tool to generate the report.</Text>
</Stack>
</div>
);
}
return (
<div className="report-container" ref={containerRef}>
<Stack gap="xl" align="center">
<div className="simulated-page">
<Stack gap="lg">
<Stack gap="xs">
<Text fw={700} size="xl" style={{ lineHeight: 1.3, wordBreak: 'break-word' }}>
{entry.fileName}
<Text component="span" fw={700}> - {t('getPdfInfo.summary.title', 'PDF Summary')}</Text>
</Text>
<Divider />
</Stack>
<SummarySection sections={sections} hideSectionTitle />
<KeyValueSection title={t('getPdfInfo.sections.metadata', 'Metadata')} anchorId="metadata" obj={sections.metadata} />
<KeyValueSection title={t('getPdfInfo.sections.formFields', 'Form Fields')} anchorId="formFields" obj={sections.formFields} />
<KeyValueSection title={t('getPdfInfo.sections.basicInfo', 'Basic Info')} anchorId="basicInfo" obj={sections.basicInfo} />
<KeyValueSection title={t('getPdfInfo.sections.documentInfo', 'Document Info')} anchorId="documentInfo" obj={sections.documentInfo} />
<KeyValueSection title={t('getPdfInfo.sections.compliance', 'Compliance')} anchorId="compliance" obj={sections.compliance} />
<KeyValueSection title={t('getPdfInfo.sections.encryption', 'Encryption')} anchorId="encryption" obj={sections.encryption} />
<KeyValueSection title={t('getPdfInfo.sections.permissions', 'Permissions')} anchorId="permissions" obj={sections.permissions} />
<TableOfContentsSection anchorId="toc" tocArray={sections.toc ?? []} />
<OtherSection anchorId="other" other={sections.other} />
<PerPageSection anchorId="perPage" perPage={sections.perPage} />
</Stack>
</div>
</Stack>
</div>
);
};
export default GetPdfInfoReportView;
@@ -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 (
<Group justify="center" gap="sm" py="md">
<Loader size="sm" />
<Text>{t('getPdfInfo.processing', 'Extracting information...')}</Text>
</Group>
);
}
if (!isLoading && operation.results.length === 0) {
return (
<Alert color="gray" variant="light" title={t('getPdfInfo.results', 'Results')}>
<Text size="sm">{t('getPdfInfo.noResults', 'Run the tool to generate a report.')}</Text>
</Alert>
);
}
return (
<Stack gap="md">
{/* No background post-processing once JSON is ready */}
{errorMessage && (
<Alert color="yellow" variant="light">
<Text size="sm">{errorMessage}</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('getPdfInfo.downloads', 'Downloads')}
</Text>
<Button
color="blue"
onClick={() => selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
>
{selectedDownloadLabel}
</Button>
</Stack>
</Stack>
);
};
export default GetPdfInfoResults;
@@ -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<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueSection: React.FC<KeyValueSectionProps> = ({ title, anchorId, obj, emptyLabel }) => {
return (
<SectionBlock title={title} anchorId={anchorId}>
<KeyValueList obj={obj} emptyLabel={emptyLabel} />
</SectionBlock>
);
};
export default KeyValueSection;
@@ -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 <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const OtherSection: React.FC<OtherSectionProps> = ({ 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 (
<SectionBlock title={t('getPdfInfo.sections.other', 'Other')} anchorId={anchorId}>
<Stack gap="sm">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.attachments', 'Attachments')}</Text>
{renderList(other?.Attachments, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.embeddedFiles', 'Embedded Files')}</Text>
{renderList(other?.EmbeddedFiles, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.javaScript', 'JavaScript')}</Text>
{renderList(other?.JavaScript, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.layers', 'Layers')}</Text>
{renderList(other?.Layers, noneDetected)}
</Stack>
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
<Accordion.Item value="structureTree">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.structureTree', 'StructureTree')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={structureTreeContent} maxHeight="20rem" />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="xmp">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.xmp', 'XMPMetadata')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={other?.XMPMetadata} maxHeight="400px" />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</SectionBlock>
);
};
export default OtherSection;
@@ -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 <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const renderFontsList = (fonts: PdfFontInfo[] | undefined, emptyText: string) => {
if (!fonts || fonts.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{fonts.map((font, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{`${font.Name ?? 'Unknown'}${font.IsEmbedded ? ' (embedded)' : ''}`}
</Text>
))}
</Stack>
);
};
const PerPageSection: React.FC<PerPageSectionProps> = ({ anchorId, perPage }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
const hasPages = perPage && Object.keys(perPage).length > 0;
return (
<SectionBlock title={t('getPdfInfo.sections.perPageInfo', 'Per Page Info')} anchorId={anchorId}>
{hasPages ? (
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
{Object.entries(perPage).map(([pageLabel, pageInfo]: [string, PdfPageInfo]) => (
<Accordion.Item key={pageLabel} value={pageLabel}>
<Accordion.Control>
<Text fw={600} size="sm">{pageLabel}</Text>
</Accordion.Control>
<Accordion.Panel>
<div style={{ backgroundColor: 'var(--bg-raised)', color: 'var(--text-primary)', borderRadius: 8, padding: 12 }}>
<Stack gap="sm">
{pageInfo?.Size && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.size', 'Size')}</Text>
<KeyValueList obj={pageInfo.Size} />
</Stack>
)}
<KeyValueList obj={{
'Rotation': pageInfo?.Rotation,
'Page Orientation': pageInfo?.['Page Orientation'],
'MediaBox': pageInfo?.MediaBox,
'CropBox': pageInfo?.CropBox,
'BleedBox': pageInfo?.BleedBox,
'TrimBox': pageInfo?.TrimBox,
'ArtBox': pageInfo?.ArtBox,
'Text Characters Count': pageInfo?.['Text Characters Count'],
}} />
{pageInfo?.Annotations && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.annotations', 'Annotations')}</Text>
<KeyValueList obj={pageInfo.Annotations} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.images', 'Images')}</Text>
{renderList(pageInfo?.Images, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.links', 'Links')}</Text>
{renderList(pageInfo?.Links, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.fonts', 'Fonts')}</Text>
{renderFontsList(pageInfo?.Fonts, noneDetected)}
</Stack>
{pageInfo?.XObjectCounts && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.xobjects', 'XObject Counts')}</Text>
<KeyValueList obj={pageInfo.XObjectCounts} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.multimedia', 'Multimedia')}</Text>
{renderList(pageInfo?.Multimedia, noneDetected)}
</Stack>
</Stack>
</div>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
) : (
<Text size="sm" c="dimmed">{noneDetected}</Text>
)}
</SectionBlock>
);
};
export default PerPageSection;
@@ -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<SummarySectionProps> = ({ 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<string, unknown> = {
[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<string, unknown> = {
[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<string, unknown> = {
[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 = (
<Stack gap="md">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.basic', 'Basic Information')}</Text>
<KeyValueList obj={summaryBlocks.basicInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.documentInfo', 'Document Information')}</Text>
<KeyValueList obj={summaryBlocks.documentInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.securityTitle', 'Security Status')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.securityStatusText}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.permSummary}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.complianceText}</Text>
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.technical', 'Technical')}</Text>
<KeyValueList obj={summaryBlocks.technical} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.overviewTitle', 'PDF Overview')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.overview}</Text>
</Stack>
</Stack>
);
if (hideSectionTitle) {
return <div id="summary">{content}</div>;
}
return (
<SectionBlock title={t('getPdfInfo.summary.title', 'PDF Summary')} anchorId="summary">
{content}
</SectionBlock>
);
};
export default SummarySection;
@@ -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<TableOfContentsSectionProps> = ({ anchorId, tocArray }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
return (
<SectionBlock title={t('getPdfInfo.sections.tableOfContents', 'Table of Contents')} anchorId={anchorId}>
{!tocArray || tocArray.length === 0 ? (
<Text size="sm" c="dimmed">{noneDetected}</Text>
) : (
<Stack gap={4}>
{tocArray.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed">
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
)}
</SectionBlock>
);
};
export default TableOfContentsSection;
@@ -0,0 +1,29 @@
import React from 'react';
import { Group, Stack, Text } from '@mantine/core';
interface KeyValueListProps {
obj?: Record<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueList: React.FC<KeyValueListProps> = ({ obj, emptyLabel }) => {
if (!obj || Object.keys(obj).length === 0) {
return <Text size="sm" c="dimmed">{emptyLabel ?? 'None detected'}</Text>;
}
return (
<Stack gap={6}>
{Object.entries(obj).map(([k, v]) => (
<Group key={k} wrap="nowrap" align="flex-start" style={{ width: '100%' }}>
<Text size="sm" style={{ minWidth: 180, maxWidth: 180, flexShrink: 0 }}>{k}</Text>
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word', flex: 1 }}>
{v == null ? '' : String(v)}
</Text>
</Group>
))}
</Stack>
);
};
export default KeyValueList;
@@ -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<ScrollableCodeBlockProps> = ({
content,
maxHeight = '400px',
emptyMessage,
}) => {
const { t } = useTranslation();
if (!content) {
return (
<Text size="sm" c="dimmed">
{emptyMessage ?? t('getPdfInfo.noneDetected', 'None detected')}
</Text>
);
}
return (
<Code
block
style={{
whiteSpace: 'pre-wrap',
backgroundColor: 'var(--bg-raised)',
color: 'var(--text-primary)',
maxHeight,
overflowY: 'auto',
}}
>
{content}
</Code>
);
};
export default ScrollableCodeBlock;
@@ -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<SectionBlockProps> = ({ title, anchorId, children }) => {
return (
<Stack gap="sm" id={anchorId}>
<Text fw={700} size="lg">{title}</Text>
<Divider />
{children}
</Stack>
);
};
export default SectionBlock;
@@ -0,0 +1,14 @@
import type { AccordionStylesNames } from '@mantine/core';
import type { CSSProperties } from 'react';
type AccordionStyles = Partial<Record<AccordionStylesNames, CSSProperties>>;
export const pdfInfoAccordionStyles: AccordionStyles = {
item: {
backgroundColor: 'var(--accordion-item-bg)',
},
control: {
backgroundColor: 'transparent',
},
};
@@ -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<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [activeImageId, setActiveImageId] = useState<string | null>(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')}
</Button>
<Button
variant="filled"
color="green"
leftSection={<SaveIcon fontSize="small" />}
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
</Button>
</Stack>
{fileName && (
@@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => {
)}
{!hasDocument && !isConverting && (
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Stack align="center" gap="md">
<DescriptionIcon sx={{ fontSize: 48 }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
</Text>
</Stack>
</Card>
<Stack
align="center"
justify="center"
style={{ gridColumn: '1 / 2', gridRow: 1, height: '100%' }}
>
<Dropzone
onDrop={(files) => {
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',
}}
>
<Stack align="center" gap="md" style={{ pointerEvents: 'none' }}>
<UploadFileIcon sx={{ fontSize: 48, color: 'var(--mantine-color-blue-5)' }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{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')}
</Text>
</Stack>
</Dropzone>
</Stack>
)}
{isConverting && (
@@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => {
</Card>
)}
{hasDocument && (
{hasDocument && !isConverting && (
<Stack
gap="lg"
className="flex-1"
@@ -2444,7 +2491,7 @@ const selectionToolbarPosition = useMemo(() => {
{/* Navigation Warning Modal */}
<NavigationWarningModal
onApplyAndContinue={onGeneratePdfForNavigation}
onApplyAndContinue={onSaveToWorkbench}
/>
</Stack>
);
@@ -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;
}
@@ -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 (
<NavigationStateContext.Provider value={stateValue}>
@@ -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
@@ -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: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"),
@@ -324,14 +342,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
getPdfInfo: {
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
@@ -891,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
pdfTextEditor: {
icon: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
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;
@@ -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<GetPdfInfoParameters> {
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<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [results, setResults] = useState<PdfInfoReportEntry[]>([]);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(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<GetPdfInfoOperationHook>(
() => ({
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,
]
);
};
@@ -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<GetPdfInfoParameters>;
export const useGetPdfInfoParameters = (): GetPdfInfoParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'get-info-on-pdf',
});
};
+7 -3
View File
@@ -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();
+2
View File
@@ -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);
+188
View File
@@ -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(() => <PictureAsPdfIcon fontSize="small" />, []);
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<PdfInfoReportData | null>(() => {
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<number | null>(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: (
<Stack gap={0}>
{CHAPTERS.map((c, idx) => (
<Stack key={c.id} gap={0}>
<UnstyledButton
onClick={() => {
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' }}
>
<Group justify="flex-start" gap="sm">
<LinkIcon fontSize="small" style={{ opacity: 0.7 }} />
<Text size="md" c="dimmed">
{t(c.labelKey, c.fallback)}
</Text>
</Group>
</UnstyledButton>
{idx < CHAPTERS.length - 1 && <Divider my={6} />}
</Stack>
))}
</Stack>
),
},
{
title: t('getPdfInfo.results', 'Results'),
isVisible: showResultsStep,
isCollapsed: false,
content: (
<GetPdfInfoResults
operation={operation}
isLoading={base.operation.isLoading}
errorMessage={base.operation.errorMessage}
/>
),
},
],
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;
@@ -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<PdfJsonDocument | null>(null);
const [groupsByPage, setGroupsByPage] = useState<TextGroup[][]>([]);
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const [fileName, setFileName] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(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<ConversionProgress | null>(null);
const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalGroupsRef = useRef<TextGroup[][]>([]);
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
const autoLoadKeyRef = useRef<string | null>(null);
const sourceFileIdRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0);
const latestPdfRequestIdRef = useRef<number | null>(null);
const loadedDocumentRef = useRef<PdfJsonDocument | null>(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<PdfTextEditorViewData>(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<PdfTextEditorViewData | null>(null);
useEffect(() => {
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
onDownloadJson: () => void;
onGeneratePdf: () => void;
onGeneratePdfForNavigation: () => Promise<void>;
onSaveToWorkbench: () => Promise<void>;
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;
}
+273
View File
@@ -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<string, unknown>[];
}
/** 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<string, string>;
/** 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';
@@ -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'