From 3cf89b6eded59c56f1ed400eab5ae1967eeb079b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:47:43 +0000 Subject: [PATCH 01/16] admin onboarding (#4863) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 19 +- frontend/src/core/components/AppProviders.tsx | 5 +- .../components/onboarding/OnboardingTour.css | 25 +++ .../components/onboarding/OnboardingTour.tsx | 185 +++++++++++++++++- .../core/components/shared/AppConfigModal.tsx | 3 +- .../core/components/shared/QuickAccessBar.tsx | 74 ++++++- .../AdminTourOrchestrationContext.tsx | 124 ++++++++++++ .../src/core/contexts/OnboardingContext.tsx | 15 +- 8 files changed, 431 insertions(+), 19 deletions(-) create mode 100644 frontend/src/core/contexts/AdminTourOrchestrationContext.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2e1417bf02..626f16f445 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3539,7 +3539,13 @@ "account": "Account", "config": "Config", "adminSettings": "Admin Settings", - "allTools": "All Tools" + "allTools": "All Tools", + "helpMenu": { + "toolsTour": "Tools Tour", + "toolsTourDesc": "Learn what the tools can do", + "adminTour": "Admin Tour", + "adminTourDesc": "Explore admin settings & features" + } }, "admin": { "error": "Error", @@ -4674,6 +4680,17 @@ "startTour": "Start Tour", "startTourDescription": "Take a guided tour of Stirling PDF's key features" }, + "adminOnboarding": { + "welcome": "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + "configButton": "Click the Config button to access all system settings and administrative controls.", + "settingsOverview": "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + "teamsAndUsers": "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + "systemCustomization": "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", + "databaseSection": "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + "connectionsSection": "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", + "adminTools": "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + "wrapUp": "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu." + }, "workspace": { "title": "Workspace", "people": { diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 46ce96477d..2108b3550a 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -14,6 +14,7 @@ import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; +import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -59,7 +60,9 @@ export function AppProviders({ children, appConfigRetryOptions }: AppProvidersPr - {children} + + {children} + diff --git a/frontend/src/core/components/onboarding/OnboardingTour.css b/frontend/src/core/components/onboarding/OnboardingTour.css index fb96ec1d28..9667e835a5 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.css +++ b/frontend/src/core/components/onboarding/OnboardingTour.css @@ -6,3 +6,28 @@ ry: 8px; filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled)); } + +/* Add glowing border to navigation items during admin tour */ +.modal-nav-item.tour-nav-glow { + position: relative; + box-shadow: + 0 0 0 2px var(--mantine-primary-color-filled), + 0 0 15px var(--mantine-primary-color-filled), + inset 0 0 15px rgba(59, 130, 246, 0.1); + border-radius: 8px; +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: + 0 0 0 3px var(--mantine-primary-color-filled), + 0 0 20px var(--mantine-primary-color-filled), + inset 0 0 20px rgba(59, 130, 246, 0.1); + } + 50% { + box-shadow: + 0 0 0 3px var(--mantine-primary-color-filled), + 0 0 30px var(--mantine-primary-color-filled), + inset 0 0 30px rgba(59, 130, 246, 0.2); + } +} diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 4a4a770f1f..8f7608b146 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { CloseButton, ActionIcon } from '@mantine/core'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; +import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import CheckIcon from '@mui/icons-material/Check'; import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal'; @@ -32,6 +33,18 @@ enum TourStep { WRAP_UP, } +enum AdminTourStep { + WELCOME, + CONFIG_BUTTON, + SETTINGS_OVERVIEW, + TEAMS_AND_USERS, + SYSTEM_CUSTOMIZATION, + DATABASE_SECTION, + CONNECTIONS_SECTION, + ADMIN_TOOLS, + WRAP_UP, +} + function TourContent() { const { isOpen } = useOnboarding(); const { setIsOpen, setCurrentStep } = useTour(); @@ -54,8 +67,36 @@ function TourContent() { export default function OnboardingTour() { const { t } = useTranslation(); - const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding(); + const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding(); const { openFilesModal, closeFilesModal } = useFilesModalContext(); + + // Helper to add glow to multiple elements + const addGlowToElements = (selectors: string[]) => { + selectors.forEach(selector => { + const element = document.querySelector(selector); + if (element) { + if (selector === '[data-tour="settings-content-area"]') { + element.classList.add('tour-content-glow'); + } else { + element.classList.add('tour-nav-glow'); + } + } + }); + }; + + // Helper to remove all glows + const removeAllGlows = () => { + document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow')); + document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow')); + }; + + // Cleanup glows when tour closes + React.useEffect(() => { + if (!isOpen) { + removeAllGlows(); + } + return () => removeAllGlows(); + }, [isOpen]); const { saveWorkbenchState, restoreWorkbenchState, @@ -70,6 +111,13 @@ export default function OnboardingTour() { modifyCropSettings, executeTool, } = useTourOrchestration(); + const { + saveAdminState, + restoreAdminState, + openConfigModal, + navigateToSection, + scrollNavToSection, + } = useAdminTourOrchestration(); // Define steps as object keyed by enum - TypeScript ensures all keys are present const stepsConfig: Record = { @@ -202,8 +250,125 @@ export default function OnboardingTour() { }, }; - // Convert to array using enum's numeric ordering - const steps = Object.values(stepsConfig); + // Define admin tour steps + const adminStepsConfig: Record = { + [AdminTourStep.WELCOME]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), + position: 'right', + padding: 10, + action: () => { + saveAdminState(); + }, + }, + [AdminTourStep.CONFIG_BUTTON]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), + position: 'right', + padding: 10, + actionAfter: () => { + openConfigModal(); + }, + }, + [AdminTourStep.SETTINGS_OVERVIEW]: { + selector: '.modal-nav', + content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), + position: 'right', + padding: 0, + action: () => { + removeAllGlows(); + }, + }, + [AdminTourStep.TEAMS_AND_USERS]: { + selector: '[data-tour="admin-people-nav"]', + highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('people'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.SYSTEM_CUSTOMIZATION]: { + selector: '[data-tour="admin-adminGeneral-nav"]', + highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminGeneral'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.DATABASE_SECTION]: { + selector: '[data-tour="admin-adminDatabase-nav"]', + highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminDatabase'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.CONNECTIONS_SECTION]: { + selector: '[data-tour="admin-adminConnections-nav"]', + highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminConnections'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + actionAfter: async () => { + // Scroll for the NEXT step before it shows + await scrollNavToSection('adminAudit'); + }, + }, + [AdminTourStep.ADMIN_TOOLS]: { + selector: '[data-tour="admin-adminAudit-nav"]', + highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), + position: 'right', + padding: 10, + action: () => { + // Just navigate, scroll already happened in previous step + removeAllGlows(); + navigateToSection('adminAudit'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + }, + }, + }; + + // Select steps based on tour type + const steps = tourType === 'admin' + ? Object.values(adminStepsConfig) + : Object.values(stepsConfig); const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: { setCurrentStep: (value: number | ((prev: number) => number)) => void; @@ -213,7 +378,11 @@ export default function OnboardingTour() { }) => { if (steps && currentStep === steps.length - 1) { setIsOpen(false); - restoreWorkbenchState(); + if (tourType === 'admin') { + restoreAdminState(); + } else { + restoreWorkbenchState(); + } completeTour(); } else if (steps) { setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); @@ -222,7 +391,11 @@ export default function OnboardingTour() { const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { setIsOpen(false); - restoreWorkbenchState(); + if (tourType === 'admin') { + restoreAdminState(); + } else { + restoreWorkbenchState(); + } completeTour(); }; @@ -243,7 +416,9 @@ export default function OnboardingTour() { }} /> { diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index a080ca3c7c..97f1ee295b 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -154,6 +154,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { opacity: isDisabled ? 0.5 : 1, cursor: isDisabled ? 'not-allowed' : 'pointer', }} + data-tour={`admin-${item.key}-nav`} > {!isMobile && ( @@ -185,7 +186,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { {/* Right content */} -
+
{/* Sticky header with section title and small close button */}
((_, ref) => { const { t } = useTranslation(); @@ -179,7 +180,7 @@ const QuickAccessBar = forwardRef((_, ref) => { size: 'lg', type: 'action', onClick: () => { - startTour(); + // This will be overridden by the wrapper logic }, }, { @@ -258,11 +259,70 @@ const QuickAccessBar = forwardRef((_, ref) => { {/* Bottom section */} - {bottomButtons.map((config, index) => ( - - {renderNavButton(config, index)} - - ))} + {bottomButtons.map((buttonConfig, index) => { + // Handle help button with menu or direct action + if (buttonConfig.id === 'help') { + const isAdmin = config?.isAdmin === true; + + // If not admin, just show button that starts tools tour directly + if (!isAdmin) { + return ( +
startTour('tools')} + > + {renderNavButton(buttonConfig, index)} +
+ ); + } + + // If admin, show menu with both options + return ( +
+ + +
{renderNavButton(buttonConfig, index)}
+
+ + } + onClick={() => startTour('tools')} + > +
+
+ {t("quickAccess.helpMenu.toolsTour", "Tools Tour")} +
+
+ {t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")} +
+
+
+ } + onClick={() => startTour('admin')} + > +
+
+ {t("quickAccess.helpMenu.adminTour", "Admin Tour")} +
+
+ {t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")} +
+
+
+
+
+
+ ); + } + + return ( + + {renderNavButton(buttonConfig, index)} + + ); + })}
diff --git a/frontend/src/core/contexts/AdminTourOrchestrationContext.tsx b/frontend/src/core/contexts/AdminTourOrchestrationContext.tsx new file mode 100644 index 0000000000..3bac8f101e --- /dev/null +++ b/frontend/src/core/contexts/AdminTourOrchestrationContext.tsx @@ -0,0 +1,124 @@ +import React, { createContext, useContext, useCallback, useRef } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; + +interface AdminTourOrchestrationContextType { + // State management + saveAdminState: () => void; + restoreAdminState: () => void; + + // Modal & navigation + openConfigModal: () => void; + closeConfigModal: () => void; + navigateToSection: (section: string) => void; + scrollNavToSection: (section: string) => void; + + // Section-specific actions + scrollToSetting: (settingId: string) => void; +} + +const AdminTourOrchestrationContext = createContext(undefined); + +export const AdminTourOrchestrationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const navigate = useNavigate(); + const location = useLocation(); + + // Store the user's location before tour starts + const savedLocationRef = useRef(''); + + const saveAdminState = useCallback(() => { + savedLocationRef.current = location.pathname; + console.log('Saving admin state, location:', location.pathname); + }, [location.pathname]); + + const restoreAdminState = useCallback(() => { + console.log('Restoring admin state, saved location:', savedLocationRef.current); + + // Navigate back to saved location or home + const targetPath = savedLocationRef.current || '/'; + navigate(targetPath, { replace: true }); + + savedLocationRef.current = ''; + }, [navigate]); + + const openConfigModal = useCallback(() => { + // Navigate to settings overview to open the modal + navigate('/settings/overview'); + }, [navigate]); + + const closeConfigModal = useCallback(() => { + // Navigate back to home to close the modal + navigate('/', { replace: true }); + }, [navigate]); + + const navigateToSection = useCallback((section: string) => { + navigate(`/settings/${section}`); + }, [navigate]); + + const scrollNavToSection = useCallback((section: string): Promise => { + return new Promise((resolve) => { + const navElement = document.querySelector(`[data-tour="admin-${section}-nav"]`) as HTMLElement; + const scrollContainer = document.querySelector('.modal-nav-scroll') as HTMLElement; + + if (navElement && scrollContainer) { + // Get the position of the nav element relative to the scroll container + const navTop = navElement.offsetTop; + const containerHeight = scrollContainer.clientHeight; + const navHeight = navElement.offsetHeight; + + // Calculate scroll position to center the element + const scrollTo = navTop - (containerHeight / 2) + (navHeight / 2); + + // Instant scroll to avoid timing issues + scrollContainer.scrollTo({ + top: Math.max(0, scrollTo), + behavior: 'auto' + }); + + // Use multiple animation frames to ensure browser has fully updated + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); + } else { + resolve(); + } + }); + }, []); + + const scrollToSetting = useCallback((settingId: string) => { + // Wait for the DOM to update, then scroll to the setting + setTimeout(() => { + const element = document.querySelector(`[data-tour="${settingId}"]`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 300); + }, []); + + const value: AdminTourOrchestrationContextType = { + saveAdminState, + restoreAdminState, + openConfigModal, + closeConfigModal, + navigateToSection, + scrollNavToSection, + scrollToSetting, + }; + + return ( + + {children} + + ); +}; + +export const useAdminTourOrchestration = (): AdminTourOrchestrationContextType => { + const context = useContext(AdminTourOrchestrationContext); + if (!context) { + throw new Error('useAdminTourOrchestration must be used within AdminTourOrchestrationProvider'); + } + return context; +}; diff --git a/frontend/src/core/contexts/OnboardingContext.tsx b/frontend/src/core/contexts/OnboardingContext.tsx index 21bdadc4d5..817f3dd5a9 100644 --- a/frontend/src/core/contexts/OnboardingContext.tsx +++ b/frontend/src/core/contexts/OnboardingContext.tsx @@ -2,14 +2,17 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr import { usePreferences } from '@app/contexts/PreferencesContext'; import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal'; +export type TourType = 'tools' | 'admin'; + interface OnboardingContextValue { isOpen: boolean; currentStep: number; + tourType: TourType; setCurrentStep: (step: number) => void; - startTour: () => void; + startTour: (type?: TourType) => void; closeTour: () => void; completeTour: () => void; - resetTour: () => void; + resetTour: (type?: TourType) => void; showWelcomeModal: boolean; setShowWelcomeModal: (show: boolean) => void; } @@ -20,6 +23,7 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch const { updatePreference } = usePreferences(); const [isOpen, setIsOpen] = useState(false); const [currentStep, setCurrentStep] = useState(0); + const [tourType, setTourType] = useState('tools'); const [showWelcomeModal, setShowWelcomeModal] = useState(false); const shouldShow = useShouldShowWelcomeModal(); @@ -30,7 +34,8 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch } }, [shouldShow]); - const startTour = useCallback(() => { + const startTour = useCallback((type: TourType = 'tools') => { + setTourType(type); setCurrentStep(0); setIsOpen(true); }, []); @@ -44,8 +49,9 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch updatePreference('hasCompletedOnboarding', true); }, [updatePreference]); - const resetTour = useCallback(() => { + const resetTour = useCallback((type: TourType = 'tools') => { updatePreference('hasCompletedOnboarding', false); + setTourType(type); setCurrentStep(0); setIsOpen(true); }, [updatePreference]); @@ -55,6 +61,7 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch value={{ isOpen, currentStep, + tourType, setCurrentStep, startTour, closeTour, From ce6b2460d871acf36fefbc61d18a6669cadd961e Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:52:13 +0000 Subject: [PATCH 02/16] Viewer update and autozoom (#4800) Updated embed PDF Added Autozoom Added file page size to metadata for use in calculations for autozoom, will come in handy elsewhere. --------- Co-authored-by: James Brunton --- frontend/package-lock.json | 84 +-- frontend/package.json | 36 +- .../core/components/viewer/EmbedPdfViewer.tsx | 13 +- .../core/components/viewer/LocalEmbedPDF.tsx | 11 +- .../components/viewer/PdfViewerToolbar.tsx | 47 +- .../components/viewer/SpreadAPIBridge.tsx | 51 +- .../core/components/viewer/ZoomAPIBridge.tsx | 280 +++++++-- frontend/src/core/contexts/ViewerContext.tsx | 558 ++++-------------- .../src/core/contexts/file/fileActions.ts | 14 +- .../src/core/contexts/viewer/viewerActions.ts | 311 ++++++++++ .../src/core/contexts/viewer/viewerBridges.ts | 174 ++++++ frontend/src/core/types/fileContext.ts | 2 + frontend/src/core/utils/pageMetadata.ts | 53 ++ frontend/src/core/utils/thumbnailUtils.ts | 16 +- frontend/src/core/utils/viewerZoom.ts | 188 ++++++ 15 files changed, 1215 insertions(+), 623 deletions(-) create mode 100644 frontend/src/core/contexts/viewer/viewerActions.ts create mode 100644 frontend/src/core/contexts/viewer/viewerBridges.ts create mode 100644 frontend/src/core/utils/pageMetadata.ts create mode 100644 frontend/src/core/utils/viewerZoom.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43ee35e16a..deb9b1a058 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,24 +10,24 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -441,7 +441,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -488,7 +487,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -512,7 +510,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -596,7 +593,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -613,7 +609,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -631,7 +626,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -668,7 +662,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -703,7 +696,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -740,7 +732,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -816,7 +807,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -972,7 +962,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1016,7 +1005,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2047,7 +2035,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2098,7 +2085,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2166,7 +2152,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3850,7 +3835,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4174,7 +4158,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4185,7 +4168,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4246,7 +4228,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4960,6 +4941,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4969,6 +4951,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4979,6 +4962,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4991,6 +4975,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5017,7 +5002,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5702,7 +5686,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6748,8 +6731,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7144,7 +7126,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7315,7 +7296,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8638,7 +8618,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9446,7 +9425,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11223,7 +11201,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11503,7 +11480,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11876,7 +11852,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11886,7 +11861,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13321,9 +13295,9 @@ } }, "node_modules/svelte": { - "version": "5.42.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz", - "integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==", + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz", + "integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==", "license": "MIT", "peer": true, "dependencies": { @@ -13557,7 +13531,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13859,7 +13832,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13942,7 +13914,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14147,7 +14118,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14299,7 +14269,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14313,7 +14282,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/package.json b/frontend/package.json index 825749f3e7..7b9480ece9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,24 +6,24 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index f85058a732..5ec3e6eb75 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -35,14 +35,12 @@ const EmbedPdfViewerContent = ({ const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); const scrollState = getScrollState(); - const zoomState = getZoomState(); - const spreadState = getSpreadState(); const rotationState = getRotationState(); // Track initial rotation to detect changes @@ -320,15 +318,6 @@ const EmbedPdfViewerContent = ({ { - // Page navigation handled by scrollActions - console.log('Navigate to page:', page); - }} - dualPage={spreadState.isDualPage} - onDualPageToggle={() => { - spreadActions.toggleSpreadMode(); - }} - currentZoom={zoomState.zoomPercent} />
diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 0d963eeca3..62d60e14f9 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -9,7 +9,7 @@ import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react'; import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react'; import { RenderPluginPackage } from '@embedpdf/plugin-render/react'; -import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react'; +import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react'; import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react'; import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; @@ -115,9 +115,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register zoom plugin with configuration createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: 1.4, // Start at 140% zoom for better readability + defaultZoomLevel: ZoomMode.FitWidth, // Start with FitWidth, will be adjusted in ZoomAPIBridge minZoom: 0.2, - maxZoom: 3.0, + maxZoom: 5.0, }), // Register tiling plugin (depends on Render, Scroll, Viewport) @@ -287,6 +287,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur minHeight: 0, minWidth: 0, contain: 'strict', + display: 'flex', + justifyContent: 'center', }} >
void; - - // Dual page toggle (placeholder for now) - dualPage?: boolean; - onDualPageToggle?: () => void; - - // Zoom controls (connected via ViewerContext) - currentZoom?: number; } export function PdfViewerToolbar({ currentPage = 1, totalPages: _totalPages = 1, onPageChange, - dualPage = false, - onDualPageToggle, - currentZoom: _currentZoom = 100, }: PdfViewerToolbarProps) { const { t } = useTranslation(); - const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer(); + const { + getScrollState, + getZoomState, + getSpreadState, + scrollActions, + zoomActions, + spreadActions, + registerImmediateZoomUpdate, + registerImmediateScrollUpdate, + registerImmediateSpreadUpdate, + } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); + const spreadState = getSpreadState(); const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage); const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140); + const [isDualPageActive, setIsDualPageActive] = useState(spreadState.isDualPage); // Register for immediate scroll updates and sync with actual scroll state useEffect(() => { @@ -53,6 +55,13 @@ export function PdfViewerToolbar({ setDisplayZoomPercent(zoomState.zoomPercent || 140); }, [zoomState.zoomPercent, registerImmediateZoomUpdate]); + useEffect(() => { + registerImmediateSpreadUpdate((_mode, isDual) => { + setIsDualPageActive(isDual); + }); + setIsDualPageActive(spreadState.isDualPage); + }, [registerImmediateSpreadUpdate, spreadState.isDualPage]); + const handleZoomOut = () => { zoomActions.zoomOut(); }; @@ -69,6 +78,10 @@ export function PdfViewerToolbar({ setPageInput(page); }; + const handleDualPageToggle = () => { + spreadActions.toggleSpreadMode(); + }; + const handleFirstPage = () => { scrollActions.scrollToFirstPage(); }; @@ -188,15 +201,19 @@ export function PdfViewerToolbar({ {/* Dual Page Toggle */} {/* Zoom Controls */} diff --git a/frontend/src/core/components/viewer/SpreadAPIBridge.tsx b/frontend/src/core/components/viewer/SpreadAPIBridge.tsx index e256ecc8d7..1163e7c7c1 100644 --- a/frontend/src/core/components/viewer/SpreadAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SpreadAPIBridge.tsx @@ -7,33 +7,36 @@ import { useViewer } from '@app/contexts/ViewerContext'; */ export function SpreadAPIBridge() { const { provides: spread, spreadMode } = useSpread(); - const { registerBridge } = useViewer(); + const { registerBridge, triggerImmediateSpreadUpdate } = useViewer(); useEffect(() => { - if (spread) { - const newState = { - spreadMode, - isDualPage: spreadMode !== SpreadMode.None - }; - - // Register this bridge with ViewerContext - registerBridge('spread', { - state: newState, - api: { - setSpreadMode: (mode: SpreadMode) => { - spread.setSpreadMode(mode); - }, - getSpreadMode: () => spread.getSpreadMode(), - toggleSpreadMode: () => { - // Toggle between None and Odd (most common dual-page mode) - const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None; - spread.setSpreadMode(newMode); - }, - SpreadMode: SpreadMode, // Export enum for reference - } - }); + if (!spread) { + return; } - }, [spread, spreadMode]); + + const newState = { + spreadMode, + isDualPage: spreadMode !== SpreadMode.None, + }; + + registerBridge('spread', { + state: newState, + api: { + setSpreadMode: (mode: SpreadMode) => { + spread.setSpreadMode(mode); + }, + getSpreadMode: () => spread.getSpreadMode(), + toggleSpreadMode: () => { + const current = spread.getSpreadMode(); + const nextMode = current === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None; + spread.setSpreadMode(nextMode); + }, + SpreadMode, + }, + }); + + triggerImmediateSpreadUpdate(spreadMode); + }, [spread, spreadMode, registerBridge, triggerImmediateSpreadUpdate]); return null; } diff --git a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx index 000bf47d99..44a4c403eb 100644 --- a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx @@ -1,68 +1,246 @@ -import { useEffect, useRef } from 'react'; -import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useZoom, ZoomMode } from '@embedpdf/plugin-zoom/react'; +import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react'; import { useViewer } from '@app/contexts/ViewerContext'; +import { useFileState } from '@app/contexts/FileContext'; +import { + determineAutoZoom, + DEFAULT_FALLBACK_ZOOM, + DEFAULT_VISIBILITY_THRESHOLD, + measureRenderedPageRect, + useFitWidthResize, + ZoomViewport, +} from '@app/utils/viewerZoom'; +import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata'; -/** - * Component that runs inside EmbedPDF context and manages zoom state locally - */ export function ZoomAPIBridge() { const { provides: zoom, state: zoomState } = useZoom(); + const { spreadMode } = useSpread(); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const hasSetInitialZoom = useRef(false); + const { selectors } = useFileState(); + + const hasSetInitialZoom = useRef(false); + const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None); + const lastFileId = useRef(undefined); + const lastAppliedZoom = useRef(null); + const [autoZoomTick, setAutoZoomTick] = useState(0); + + const scheduleAutoZoom = useCallback(() => { + hasSetInitialZoom.current = false; + lastAppliedZoom.current = null; + setAutoZoomTick((tick) => tick + 1); + }, []); + + const requestFitWidth = useCallback(() => { + if (zoom) { + zoom.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }); + } + }, [zoom]); + + const stubs = selectors.getStirlingFileStubs(); + const firstFileStub = stubs[0]; + const firstFileId = firstFileStub?.id; - // Set initial zoom once when plugin is ready useEffect(() => { - if (!zoom || hasSetInitialZoom.current) { + if (!firstFileId) { + hasSetInitialZoom.current = false; + lastFileId.current = undefined; + lastAppliedZoom.current = null; return; } - let retryTimer: ReturnType | undefined; - const attemptInitialZoom = () => { - try { - zoom.requestZoom(1.4); - hasSetInitialZoom.current = true; - } catch (error) { - console.log('Zoom initialization delayed, viewport not ready:', error); - retryTimer = setTimeout(() => { - try { - zoom.requestZoom(1.4); - hasSetInitialZoom.current = true; - } catch (retryError) { - console.log('Zoom initialization failed:', retryError); - } - }, 200); - } - }; - - const timer = setTimeout(attemptInitialZoom, 50); - - return () => { - clearTimeout(timer); - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [zoom, zoomState]); + if (firstFileId !== lastFileId.current) { + lastFileId.current = firstFileId; + scheduleAutoZoom(); + } + }, [firstFileId, scheduleAutoZoom]); useEffect(() => { - if (zoom && zoomState) { - // Update local state - const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4; - const newState = { - currentZoom: currentZoomLevel, - zoomPercent: Math.round(currentZoomLevel * 100), - }; + const currentSpreadMode = spreadMode ?? SpreadMode.None; + if (currentSpreadMode !== lastSpreadMode.current) { + lastSpreadMode.current = currentSpreadMode; - // Trigger immediate update for responsive UI - triggerImmediateZoomUpdate(newState.zoomPercent); - - // Register this bridge with ViewerContext - registerBridge('zoom', { - state: newState, - api: zoom - }); + const hadTrackedAutoZoom = lastAppliedZoom.current !== null; + const zoomLevel = zoomState?.zoomLevel; + if ( + zoomLevel === ZoomMode.FitWidth || + zoomLevel === ZoomMode.Automatic || + hadTrackedAutoZoom + ) { + requestFitWidth(); + scheduleAutoZoom(); + } } - }, [zoom, zoomState]); + }, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]); + + const getViewportSnapshot = useCallback((): ZoomViewport | null => { + if (!zoomState || typeof zoomState !== 'object') { + return null; + } + + if ('viewport' in zoomState) { + const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport; + return candidate ?? null; + } + + return null; + }, [zoomState]); + + const isManagedZoom = + !!zoom && + (zoomState?.zoomLevel === ZoomMode.FitWidth || + zoomState?.zoomLevel === ZoomMode.Automatic || + lastAppliedZoom.current !== null); + + useFitWidthResize({ + isManaged: isManagedZoom, + requestFitWidth, + onDebouncedResize: scheduleAutoZoom, + }); + + useEffect(() => { + if (!zoom || !zoomState) { + return; + } + + if (!firstFileId) { + return; + } + + if (hasSetInitialZoom.current) { + return; + } + + if (zoomState.zoomLevel !== ZoomMode.FitWidth) { + if (zoomState.zoomLevel === ZoomMode.Automatic) { + requestFitWidth(); + } + return; + } + + const fitWidthZoom = zoomState.currentZoomLevel; + if (!fitWidthZoom || fitWidthZoom <= 0) { + return; + } + + const applyTrackedZoom = (level: number | ZoomMode, effectiveZoom: number) => { + zoom.requestZoom(level, { vx: 0.5, vy: 0 }); + lastAppliedZoom.current = effectiveZoom; + triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100)); + hasSetInitialZoom.current = true; + }; + + let cancelled = false; + + const applyAutoZoom = async () => { + const currentSpreadMode = spreadMode ?? SpreadMode.None; + const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1; + const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub); + + const viewport = getViewportSnapshot(); + + if (cancelled) { + return; + } + + const metrics = viewport ?? {}; + const viewportWidth = + metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0; + const viewportHeight = + metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0; + + if (viewportWidth <= 0 || viewportHeight <= 0) { + return; + } + + const pageRect = await measureRenderedPageRect({ + shouldCancel: () => cancelled, + }); + if (cancelled) { + return; + } + + const decision = determineAutoZoom({ + viewportWidth, + viewportHeight, + fitWidthZoom, + pagesPerSpread, + pageRect: pageRect + ? { width: pageRect.width, height: pageRect.height } + : undefined, + metadataAspectRatio: metadataAspectRatio ?? null, + visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD, + fallbackZoom: DEFAULT_FALLBACK_ZOOM, + }); + + if (decision.type === 'fallback') { + applyTrackedZoom(decision.zoom, decision.zoom); + return; + } + + if (decision.type === 'fitWidth') { + applyTrackedZoom(ZoomMode.FitWidth, fitWidthZoom); + return; + } + + applyTrackedZoom(decision.zoom, decision.zoom); + }; + + applyAutoZoom(); + + return () => { + cancelled = true; + }; + }, [ + zoom, + zoomState, + firstFileId, + firstFileStub, + requestFitWidth, + getViewportSnapshot, + autoZoomTick, + spreadMode, + triggerImmediateZoomUpdate, + ]); + + useEffect(() => { + if (!zoom) { + return; + } + + const unsubscribe = zoom.onZoomChange((event: { newZoom?: number }) => { + if (typeof event?.newZoom !== 'number') { + return; + } + lastAppliedZoom.current = event.newZoom; + triggerImmediateZoomUpdate(Math.round(event.newZoom * 100)); + }); + + return () => { + unsubscribe(); + }; + }, [zoom, triggerImmediateZoomUpdate]); + + useEffect(() => { + if (!zoom || !zoomState) { + return; + } + + const currentZoomLevel = + lastAppliedZoom.current ?? zoomState.currentZoomLevel ?? 1; + + const newState = { + currentZoom: currentZoomLevel, + zoomPercent: Math.round(currentZoomLevel * 100), + }; + + triggerImmediateZoomUpdate(newState.zoomPercent); + + registerBridge('zoom', { + state: newState, + api: zoom, + }); + }, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]); return null; } diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 8e0bea44a2..2ffe2210d7 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -1,112 +1,55 @@ -import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; -import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import React, { + createContext, + useContext, + useState, + ReactNode, + useRef, + useCallback, +} from 'react'; import { useNavigation } from '@app/contexts/NavigationContext'; +import { + createViewerActions, + ScrollActions, + ZoomActions, + PanActions, + SelectionActions, + SpreadActions, + RotationActions, + SearchActions, + ExportActions, +} from '@app/contexts/viewer/viewerActions'; +import { + BridgeRef, + BridgeApiMap, + BridgeStateMap, + BridgeKey, + ViewerBridgeRegistry, + createBridgeRegistry, + registerBridge as setBridgeRef, + ScrollState, + ZoomState, + PanState, + SelectionState, + SpreadState, + RotationState, + SearchState, + ExportState, + ThumbnailAPIWrapper, +} from '@app/contexts/viewer/viewerBridges'; +import { SpreadMode } from '@embedpdf/plugin-spread/react'; -// Bridge API interfaces - these match what the bridges provide -interface ScrollAPIWrapper { - scrollToPage: (params: { pageNumber: number }) => void; - scrollToPreviousPage: () => void; - scrollToNextPage: () => void; -} +function useImmediateNotifier() { + const callbackRef = useRef<((...args: Args) => void) | null>(null); -interface ZoomAPIWrapper { - zoomIn: () => void; - zoomOut: () => void; - toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; -} + const register = useCallback((callback: (...args: Args) => void) => { + callbackRef.current = callback; + }, []); -interface PanAPIWrapper { - enable: () => void; - disable: () => void; - toggle: () => void; -} + const trigger = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); -interface SelectionAPIWrapper { - copyToClipboard: () => void; - getSelectedText: () => string | any; - getFormattedSelection: () => any; -} - -interface SpreadAPIWrapper { - setSpreadMode: (mode: SpreadMode) => void; - getSpreadMode: () => SpreadMode | null; - toggleSpreadMode: () => void; -} - -interface RotationAPIWrapper { - rotateForward: () => void; - rotateBackward: () => void; - setRotation: (rotation: number) => void; - getRotation: () => number; -} - -interface SearchAPIWrapper { - search: (query: string) => Promise; - clear: () => void; - next: () => void; - previous: () => void; -} - -interface ThumbnailAPIWrapper { - renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise }; -} - -interface ExportAPIWrapper { - download: () => void; - saveAsCopy: () => { toPromise: () => Promise }; -} - - -// State interfaces - represent the shape of data from each bridge -interface ScrollState { - currentPage: number; - totalPages: number; -} - -interface ZoomState { - currentZoom: number; - zoomPercent: number; -} - -interface PanState { - isPanning: boolean; -} - -interface SelectionState { - hasSelection: boolean; -} - -interface SpreadState { - spreadMode: SpreadMode; - isDualPage: boolean; -} - -interface RotationState { - rotation: number; -} - -interface SearchResult { - pageIndex: number; - rects: Array<{ - origin: { x: number; y: number }; - size: { width: number; height: number }; - }>; -} - -interface SearchState { - results: SearchResult[] | null; - activeIndex: number; -} - -interface ExportState { - canExport: boolean; -} - -// Bridge registration interface - bridges register with state and API -interface BridgeRef { - state: TState; - api: TApi; + return { register, trigger }; } /** @@ -150,66 +93,28 @@ interface ViewerContextType { // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => void; registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void; + registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void; triggerImmediateZoomUpdate: (zoomPercent: number) => void; + triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void; // Action handlers - call EmbedPDF APIs directly - scrollActions: { - scrollToPage: (page: number) => void; - scrollToFirstPage: () => void; - scrollToPreviousPage: () => void; - scrollToNextPage: () => void; - scrollToLastPage: () => void; - }; - - zoomActions: { - zoomIn: () => void; - zoomOut: () => void; - toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; - }; - - panActions: { - enablePan: () => void; - disablePan: () => void; - togglePan: () => void; - }; - - selectionActions: { - copyToClipboard: () => void; - getSelectedText: () => string; - getFormattedSelection: () => unknown; - }; - - spreadActions: { - setSpreadMode: (mode: SpreadMode) => void; - getSpreadMode: () => SpreadMode | null; - toggleSpreadMode: () => void; - }; - - rotationActions: { - rotateForward: () => void; - rotateBackward: () => void; - setRotation: (rotation: number) => void; - getRotation: () => number; - }; - - searchActions: { - search: (query: string) => Promise; - next: () => void; - previous: () => void; - clear: () => void; - }; - - exportActions: { - download: () => void; - saveAsCopy: () => Promise; - }; + scrollActions: ScrollActions; + zoomActions: ZoomActions; + panActions: PanActions; + selectionActions: SelectionActions; + spreadActions: SpreadActions; + rotationActions: RotationActions; + searchActions: SearchActions; + exportActions: ExportActions; // Bridge registration - internal use by bridges - registerBridge: (type: string, ref: BridgeRef) => void; + registerBridge: ( + type: K, + ref: BridgeRef + ) => void; } export const ViewerContext = createContext(null); @@ -229,56 +134,51 @@ export const ViewerProvider: React.FC = ({ children }) => { useNavigation(); // Bridge registry - bridges register their state and APIs here - const bridgeRefs = useRef({ - scroll: null as BridgeRef | null, - zoom: null as BridgeRef | null, - pan: null as BridgeRef | null, - selection: null as BridgeRef | null, - search: null as BridgeRef | null, - spread: null as BridgeRef | null, - rotation: null as BridgeRef | null, - thumbnail: null as BridgeRef | null, - export: null as BridgeRef | null, - }); + const bridgeRefs = useRef(createBridgeRegistry()); - // Immediate zoom callback for responsive display updates - const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null); + const { + register: registerImmediateZoomUpdate, + trigger: triggerImmediateZoomInternal, + } = useImmediateNotifier<[number]>(); + const { + register: registerImmediateScrollUpdate, + trigger: triggerImmediateScrollInternal, + } = useImmediateNotifier<[number, number]>(); + const { + register: registerImmediateSpreadUpdate, + trigger: triggerImmediateSpreadInternal, + } = useImmediateNotifier<[SpreadMode, boolean]>(); - // Immediate scroll callback for responsive display updates - const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null); + const triggerImmediateZoomUpdate = useCallback( + (percent: number) => { + triggerImmediateZoomInternal(percent); + }, + [triggerImmediateZoomInternal] + ); - const registerBridge = (type: string, ref: BridgeRef) => { - // Type-safe assignment - we know the bridges will provide correct types - switch (type) { - case 'scroll': - bridgeRefs.current.scroll = ref as BridgeRef; - break; - case 'zoom': - bridgeRefs.current.zoom = ref as BridgeRef; - break; - case 'pan': - bridgeRefs.current.pan = ref as BridgeRef; - break; - case 'selection': - bridgeRefs.current.selection = ref as BridgeRef; - break; - case 'search': - bridgeRefs.current.search = ref as BridgeRef; - break; - case 'spread': - bridgeRefs.current.spread = ref as BridgeRef; - break; - case 'rotation': - bridgeRefs.current.rotation = ref as BridgeRef; - break; - case 'thumbnail': - bridgeRefs.current.thumbnail = ref as BridgeRef; - break; - case 'export': - bridgeRefs.current.export = ref as BridgeRef; - break; - } - }; + const triggerImmediateScrollUpdate = useCallback( + (currentPage: number, totalPages: number) => { + triggerImmediateScrollInternal(currentPage, totalPages); + }, + [triggerImmediateScrollInternal] + ); + + const triggerImmediateSpreadUpdate = useCallback( + (mode: SpreadMode, isDualPage: boolean = mode !== SpreadMode.None) => { + triggerImmediateSpreadInternal(mode, isDualPage); + }, + [triggerImmediateSpreadInternal] + ); + + const registerBridge = useCallback( + ( + type: K, + ref: BridgeRef + ) => { + setBridgeRef(bridgeRefs.current, type, ref); + }, + [] + ); const toggleThumbnailSidebar = () => { setIsThumbnailSidebarVisible(prev => !prev); @@ -334,241 +234,21 @@ export const ViewerProvider: React.FC = ({ children }) => { }; // Action handlers - call APIs directly - const scrollActions = { - scrollToPage: (page: number) => { - const api = bridgeRefs.current.scroll?.api; - if (api?.scrollToPage) { - api.scrollToPage({ pageNumber: page }); - } - }, - scrollToFirstPage: () => { - const api = bridgeRefs.current.scroll?.api; - if (api?.scrollToPage) { - api.scrollToPage({ pageNumber: 1 }); - } - }, - scrollToPreviousPage: () => { - const api = bridgeRefs.current.scroll?.api; - if (api?.scrollToPreviousPage) { - api.scrollToPreviousPage(); - } - }, - scrollToNextPage: () => { - const api = bridgeRefs.current.scroll?.api; - if (api?.scrollToNextPage) { - api.scrollToNextPage(); - } - }, - scrollToLastPage: () => { - const scrollState = getScrollState(); - const api = bridgeRefs.current.scroll?.api; - if (api?.scrollToPage && scrollState.totalPages > 0) { - api.scrollToPage({ pageNumber: scrollState.totalPages }); - } - } - }; - - const zoomActions = { - zoomIn: () => { - const api = bridgeRefs.current.zoom?.api; - if (api?.zoomIn) { - // Update display immediately if callback is registered - if (immediateZoomUpdateCallback.current) { - const currentState = getZoomState(); - const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300); - immediateZoomUpdateCallback.current(newPercent); - } - api.zoomIn(); - } - }, - zoomOut: () => { - const api = bridgeRefs.current.zoom?.api; - if (api?.zoomOut) { - // Update display immediately if callback is registered - if (immediateZoomUpdateCallback.current) { - const currentState = getZoomState(); - const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20); - immediateZoomUpdateCallback.current(newPercent); - } - api.zoomOut(); - } - }, - toggleMarqueeZoom: () => { - const api = bridgeRefs.current.zoom?.api; - if (api?.toggleMarqueeZoom) { - api.toggleMarqueeZoom(); - } - }, - requestZoom: (level: number) => { - const api = bridgeRefs.current.zoom?.api; - if (api?.requestZoom) { - api.requestZoom(level); - } - } - }; - - const panActions = { - enablePan: () => { - const api = bridgeRefs.current.pan?.api; - if (api?.enable) { - api.enable(); - } - }, - disablePan: () => { - const api = bridgeRefs.current.pan?.api; - if (api?.disable) { - api.disable(); - } - }, - togglePan: () => { - const api = bridgeRefs.current.pan?.api; - if (api?.toggle) { - api.toggle(); - } - } - }; - - const selectionActions = { - copyToClipboard: () => { - const api = bridgeRefs.current.selection?.api; - if (api?.copyToClipboard) { - api.copyToClipboard(); - } - }, - getSelectedText: () => { - const api = bridgeRefs.current.selection?.api; - if (api?.getSelectedText) { - return api.getSelectedText(); - } - return ''; - }, - getFormattedSelection: () => { - const api = bridgeRefs.current.selection?.api; - if (api?.getFormattedSelection) { - return api.getFormattedSelection(); - } - return null; - } - }; - - const spreadActions = { - setSpreadMode: (mode: SpreadMode) => { - const api = bridgeRefs.current.spread?.api; - if (api?.setSpreadMode) { - api.setSpreadMode(mode); - } - }, - getSpreadMode: () => { - const api = bridgeRefs.current.spread?.api; - if (api?.getSpreadMode) { - return api.getSpreadMode(); - } - return null; - }, - toggleSpreadMode: () => { - const api = bridgeRefs.current.spread?.api; - if (api?.toggleSpreadMode) { - api.toggleSpreadMode(); - } - } - }; - - const rotationActions = { - rotateForward: () => { - const api = bridgeRefs.current.rotation?.api; - if (api?.rotateForward) { - api.rotateForward(); - } - }, - rotateBackward: () => { - const api = bridgeRefs.current.rotation?.api; - if (api?.rotateBackward) { - api.rotateBackward(); - } - }, - setRotation: (rotation: number) => { - const api = bridgeRefs.current.rotation?.api; - if (api?.setRotation) { - api.setRotation(rotation); - } - }, - getRotation: () => { - const api = bridgeRefs.current.rotation?.api; - if (api?.getRotation) { - return api.getRotation(); - } - return 0; - } - }; - - const searchActions = { - search: async (query: string) => { - const api = bridgeRefs.current.search?.api; - if (api?.search) { - return api.search(query); - } - }, - next: () => { - const api = bridgeRefs.current.search?.api; - if (api?.next) { - api.next(); - } - }, - previous: () => { - const api = bridgeRefs.current.search?.api; - if (api?.previous) { - api.previous(); - } - }, - clear: () => { - const api = bridgeRefs.current.search?.api; - if (api?.clear) { - api.clear(); - } - } - }; - - const exportActions = { - download: () => { - const api = bridgeRefs.current.export?.api; - if (api?.download) { - api.download(); - } - }, - saveAsCopy: async () => { - const api = bridgeRefs.current.export?.api; - if (api?.saveAsCopy) { - try { - const result = api.saveAsCopy(); - return await result.toPromise(); - } catch (error) { - console.error('Failed to save PDF copy:', error); - return null; - } - } - return null; - } - }; - - const registerImmediateZoomUpdate = (callback: (percent: number) => void) => { - immediateZoomUpdateCallback.current = callback; - }; - - const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => { - immediateScrollUpdateCallback.current = callback; - }; - - const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => { - if (immediateScrollUpdateCallback.current) { - immediateScrollUpdateCallback.current(currentPage, totalPages); - } - }; - - const triggerImmediateZoomUpdate = (zoomPercent: number) => { - if (immediateZoomUpdateCallback.current) { - immediateZoomUpdateCallback.current(zoomPercent); - } - }; + const { + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + } = createViewerActions({ + registry: bridgeRefs, + getScrollState, + getZoomState, + triggerImmediateZoomUpdate, + }); const value: ViewerContextType = { // UI state @@ -600,8 +280,10 @@ export const ViewerProvider: React.FC = ({ children }) => { // Immediate updates registerImmediateZoomUpdate, registerImmediateScrollUpdate, + registerImmediateSpreadUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, + triggerImmediateSpreadUpdate, // Actions scrollActions, diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 5b4d1d2d96..0a37134f12 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -58,14 +58,21 @@ const addFilesMutex = new SimpleMutex(); /** * Helper to create ProcessedFile metadata structure */ -export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) { +export function createProcessedFile( + pageCount: number, + thumbnail?: string, + pageRotations?: number[], + pageDimensions?: Array<{ width: number; height: number }> +) { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ pageNumber: index + 1, thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially rotation: pageRotations?.[index] ?? 0, - splitBefore: false + splitBefore: false, + width: pageDimensions?.[index]?.width, + height: pageDimensions?.[index]?.height })), thumbnailUrl: thumbnail, lastProcessed: Date.now() @@ -92,7 +99,8 @@ export async function generateProcessedFileMetadata(file: File): Promise void; + scrollToFirstPage: () => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; + scrollToLastPage: () => void; +} + +export interface ZoomActions { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; +} + +export interface PanActions { + enablePan: () => void; + disablePan: () => void; + togglePan: () => void; +} + +export interface SelectionActions { + copyToClipboard: () => void; + getSelectedText: () => string; + getFormattedSelection: () => any; +} + +export interface SpreadActions { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; +} + +export interface RotationActions { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; +} + +export interface SearchActions { + search: (query: string) => Promise | undefined; + next: () => void; + previous: () => void; + clear: () => void; +} + +export interface ExportActions { + download: () => void; + saveAsCopy: () => Promise; +} + +export interface ViewerActionsBundle { + scrollActions: ScrollActions; + zoomActions: ZoomActions; + panActions: PanActions; + selectionActions: SelectionActions; + spreadActions: SpreadActions; + rotationActions: RotationActions; + searchActions: SearchActions; + exportActions: ExportActions; +} + +interface ViewerActionDependencies { + registry: MutableRefObject; + getScrollState: () => ScrollState; + getZoomState: () => ZoomState; + triggerImmediateZoomUpdate: (percent: number) => void; +} + +export function createViewerActions({ + registry, + getScrollState, + getZoomState, + triggerImmediateZoomUpdate, +}: ViewerActionDependencies): ViewerActionsBundle { + const scrollActions: ScrollActions = { + scrollToPage: (page: number) => { + const api = registry.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: page }); + } + }, + scrollToFirstPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: 1 }); + } + }, + scrollToPreviousPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToPreviousPage) { + api.scrollToPreviousPage(); + } + }, + scrollToNextPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToNextPage) { + api.scrollToNextPage(); + } + }, + scrollToLastPage: () => { + const api = registry.current.scroll?.api; + const state = getScrollState(); + if (api?.scrollToPage && state.totalPages > 0) { + api.scrollToPage({ pageNumber: state.totalPages }); + } + }, + }; + + const zoomActions: ZoomActions = { + zoomIn: () => { + const api = registry.current.zoom?.api; + if (api?.zoomIn) { + const currentState = getZoomState(); + const newPercent = Math.min( + Math.round(currentState.zoomPercent * 1.2), + 300 + ); + triggerImmediateZoomUpdate(newPercent); + api.zoomIn(); + } + }, + zoomOut: () => { + const api = registry.current.zoom?.api; + if (api?.zoomOut) { + const currentState = getZoomState(); + const newPercent = Math.max( + Math.round(currentState.zoomPercent / 1.2), + 20 + ); + triggerImmediateZoomUpdate(newPercent); + api.zoomOut(); + } + }, + toggleMarqueeZoom: () => { + const api = registry.current.zoom?.api; + if (api?.toggleMarqueeZoom) { + api.toggleMarqueeZoom(); + } + }, + requestZoom: (level: number) => { + const api = registry.current.zoom?.api; + if (api?.requestZoom) { + api.requestZoom(level); + } + }, + }; + + const panActions: PanActions = { + enablePan: () => { + const api = registry.current.pan?.api; + if (api?.enable) { + api.enable(); + } + }, + disablePan: () => { + const api = registry.current.pan?.api; + if (api?.disable) { + api.disable(); + } + }, + togglePan: () => { + const api = registry.current.pan?.api; + if (api?.toggle) { + api.toggle(); + } + }, + }; + + const selectionActions: SelectionActions = { + copyToClipboard: () => { + const api = registry.current.selection?.api; + if (api?.copyToClipboard) { + api.copyToClipboard(); + } + }, + getSelectedText: () => { + const api = registry.current.selection?.api; + if (api?.getSelectedText) { + return api.getSelectedText() ?? ''; + } + return ''; + }, + getFormattedSelection: () => { + const api = registry.current.selection?.api; + if (api?.getFormattedSelection) { + return api.getFormattedSelection(); + } + return null; + }, + }; + + const spreadActions: SpreadActions = { + setSpreadMode: (mode: SpreadMode) => { + const api = registry.current.spread?.api; + if (api?.setSpreadMode) { + api.setSpreadMode(mode); + } + }, + getSpreadMode: () => { + const api = registry.current.spread?.api; + if (api?.getSpreadMode) { + return api.getSpreadMode(); + } + return null; + }, + toggleSpreadMode: () => { + const api = registry.current.spread?.api; + if (api?.toggleSpreadMode) { + api.toggleSpreadMode(); + } + }, + }; + + const rotationActions: RotationActions = { + rotateForward: () => { + const api = registry.current.rotation?.api; + if (api?.rotateForward) { + api.rotateForward(); + } + }, + rotateBackward: () => { + const api = registry.current.rotation?.api; + if (api?.rotateBackward) { + api.rotateBackward(); + } + }, + setRotation: (rotation: number) => { + const api = registry.current.rotation?.api; + if (api?.setRotation) { + api.setRotation(rotation); + } + }, + getRotation: () => { + const api = registry.current.rotation?.api; + if (api?.getRotation) { + return api.getRotation(); + } + return 0; + }, + }; + + const searchActions: SearchActions = { + search: (query: string) => { + const api = registry.current.search?.api; + if (api?.search) { + return api.search(query); + } + }, + next: () => { + const api = registry.current.search?.api; + if (api?.next) { + api.next(); + } + }, + previous: () => { + const api = registry.current.search?.api; + if (api?.previous) { + api.previous(); + } + }, + clear: () => { + const api = registry.current.search?.api; + if (api?.clear) { + api.clear(); + } + }, + }; + + const exportActions: ExportActions = { + download: () => { + const api = registry.current.export?.api; + if (api?.download) { + api.download(); + } + }, + saveAsCopy: async () => { + const api = registry.current.export?.api; + if (api?.saveAsCopy) { + try { + const result = api.saveAsCopy(); + return await result.toPromise(); + } catch (error) { + console.error('Failed to save PDF copy:', error); + return null; + } + } + return null; + }, + }; + + return { + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + }; +} diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts new file mode 100644 index 0000000000..032a05e18d --- /dev/null +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -0,0 +1,174 @@ +import { SpreadMode } from '@embedpdf/plugin-spread/react'; + +export interface ScrollAPIWrapper { + scrollToPage: (params: { pageNumber: number }) => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; +} + +export interface ZoomAPIWrapper { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; +} + +export interface PanAPIWrapper { + enable: () => void; + disable: () => void; + toggle: () => void; + makePanDefault: () => void; +} + +export interface SelectionAPIWrapper { + copyToClipboard: () => void; + getSelectedText: () => string | any; + getFormattedSelection: () => any; +} + +export interface SpreadAPIWrapper { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; + SpreadMode: typeof SpreadMode; +} + +export interface RotationAPIWrapper { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; +} + +export interface SearchAPIWrapper { + search: (query: string) => Promise; + clear: () => void; + next: () => void; + previous: () => void; + goToResult: (index: number) => void; +} + +export interface ThumbnailAPIWrapper { + renderThumb: (pageIndex: number, scale: number) => { + toPromise: () => Promise; + }; +} + +export interface ExportAPIWrapper { + download: () => void; + saveAsCopy: () => { toPromise: () => Promise }; +} + +export interface ScrollState { + currentPage: number; + totalPages: number; +} + +export interface ZoomState { + currentZoom: number; + zoomPercent: number; +} + +export interface PanState { + isPanning: boolean; +} + +export interface SelectionState { + hasSelection: boolean; +} + +export interface SpreadState { + spreadMode: SpreadMode; + isDualPage: boolean; +} + +export interface RotationState { + rotation: number; +} + +export interface SearchResult { + pageIndex: number; + rects: Array<{ + origin: { x: number; y: number }; + size: { width: number; height: number }; + }>; +} + +export interface SearchState { + results: SearchResult[] | null; + activeIndex: number; +} + +export interface ExportState { + canExport: boolean; +} + +export interface BridgeRef { + state: TState; + api: TApi; +} + +export interface BridgeStateMap { + scroll: ScrollState; + zoom: ZoomState; + pan: PanState; + selection: SelectionState; + spread: SpreadState; + rotation: RotationState; + search: SearchState; + thumbnail: unknown; + export: ExportState; +} + +export interface BridgeApiMap { + scroll: ScrollAPIWrapper; + zoom: ZoomAPIWrapper; + pan: PanAPIWrapper; + selection: SelectionAPIWrapper; + spread: SpreadAPIWrapper; + rotation: RotationAPIWrapper; + search: SearchAPIWrapper; + thumbnail: ThumbnailAPIWrapper; + export: ExportAPIWrapper; +} + +export type BridgeKey = keyof BridgeStateMap; + +export type ViewerBridgeRegistry = { + [K in BridgeKey]: BridgeRef | null; +}; + +export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ + scroll: null, + zoom: null, + pan: null, + selection: null, + spread: null, + rotation: null, + search: null, + thumbnail: null, + export: null, +}); + +export function registerBridge( + registry: ViewerBridgeRegistry, + type: K, + ref: BridgeRef +): void { + registry[type] = ref as ViewerBridgeRegistry[K]; +} + +export function getBridgeState( + registry: ViewerBridgeRegistry, + type: K, + fallback: BridgeStateMap[K] +): BridgeStateMap[K] { + return registry[type]?.state ?? fallback; +} + +export function getBridgeApi( + registry: ViewerBridgeRegistry, + type: K +): BridgeApiMap[K] | null { + return registry[type]?.api ?? null; +} diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index fa8a5da455..3ec4380cc4 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -14,6 +14,8 @@ export interface ProcessedFilePage { pageNumber?: number; rotation?: number; splitBefore?: boolean; + width?: number; + height?: number; [key: string]: any; } diff --git a/frontend/src/core/utils/pageMetadata.ts b/frontend/src/core/utils/pageMetadata.ts new file mode 100644 index 0000000000..fa9b4d7cc4 --- /dev/null +++ b/frontend/src/core/utils/pageMetadata.ts @@ -0,0 +1,53 @@ +import { + ProcessedFileMetadata, + ProcessedFilePage, + StirlingFileStub, +} from '@app/types/fileContext'; + +export interface PageDimensions { + width: number | null; + height: number | null; +} + +export function getPageDimensions( + page?: ProcessedFilePage | null +): PageDimensions { + const width = + typeof page?.width === 'number' && page.width > 0 ? page.width : null; + const height = + typeof page?.height === 'number' && page.height > 0 ? page.height : null; + + return { width, height }; +} + +export function getFirstPageDimensionsFromMetadata( + metadata?: ProcessedFileMetadata | null +): PageDimensions { + if (!metadata?.pages?.length) { + return { width: null, height: null }; + } + + return getPageDimensions(metadata.pages[0]); +} + +export function getFirstPageDimensionsFromStub( + file?: StirlingFileStub +): PageDimensions { + return getFirstPageDimensionsFromMetadata(file?.processedFile); +} + +export function getFirstPageAspectRatioFromMetadata( + metadata?: ProcessedFileMetadata | null +): number | null { + const { width, height } = getFirstPageDimensionsFromMetadata(metadata); + if (width && height) { + return height / width; + } + return null; +} + +export function getFirstPageAspectRatioFromStub( + file?: StirlingFileStub +): number | null { + return getFirstPageAspectRatioFromMetadata(file?.processedFile); +} diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 8faec2644c..88c4aeaefb 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -4,6 +4,7 @@ export interface ThumbnailWithMetadata { thumbnail: string; // Always returns a thumbnail (placeholder if needed) pageCount: number; pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) + pageDimensions?: Array<{ width: number; height: number }>; } interface ColorScheme { @@ -402,12 +403,18 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b const pageCount = pdf.numPages; const page = await pdf.getPage(1); + const pageDimensions: Array<{ width: number; height: number }> = []; // If applyRotation is false, render without rotation (for CSS-based rotation) // If applyRotation is true, let PDF.js apply rotation (for static display) const viewport = applyRotation ? page.getViewport({ scale }) : page.getViewport({ scale, rotation: 0 }); + const baseViewport = page.getViewport({ scale: 1, rotation: 0 }); + pageDimensions[0] = { + width: baseViewport.width, + height: baseViewport.height + }; const canvas = document.createElement("canvas"); canvas.width = viewport.width; @@ -428,10 +435,17 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b const p = await pdf.getPage(i); const rotation = p.rotate || 0; pageRotations.push(rotation); + if (!pageDimensions[i - 1]) { + const pageViewport = p.getViewport({ scale: 1, rotation: 0 }); + pageDimensions[i - 1] = { + width: pageViewport.width, + height: pageViewport.height + }; + } } pdfWorkerManager.destroyDocument(pdf); - return { thumbnail, pageCount, pageRotations }; + return { thumbnail, pageCount, pageRotations, pageDimensions }; } catch (error) { if (error instanceof Error && error.name === "PasswordException") { diff --git a/frontend/src/core/utils/viewerZoom.ts b/frontend/src/core/utils/viewerZoom.ts new file mode 100644 index 0000000000..1fa1fb492f --- /dev/null +++ b/frontend/src/core/utils/viewerZoom.ts @@ -0,0 +1,188 @@ +import { useEffect, useRef } from 'react'; + +export const DEFAULT_VISIBILITY_THRESHOLD = 80; // Require at least 80% of the page height to be visible +export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present + +export interface ZoomViewport { + clientWidth?: number; + clientHeight?: number; + width?: number; + height?: number; +} + +export type AutoZoomDecision = + | { type: 'fallback'; zoom: number } + | { type: 'fitWidth' } + | { type: 'adjust'; zoom: number }; + +export interface AutoZoomParams { + viewportWidth: number; + viewportHeight: number; + fitWidthZoom: number; + pagesPerSpread: number; + pageRect?: { width: number; height: number } | null; + metadataAspectRatio?: number | null; + visibilityThreshold?: number; + fallbackZoom?: number; +} + +export function determineAutoZoom({ + viewportWidth, + viewportHeight, + fitWidthZoom, + pagesPerSpread, + pageRect, + metadataAspectRatio, + visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD, + fallbackZoom = DEFAULT_FALLBACK_ZOOM, +}: AutoZoomParams): AutoZoomDecision { + const rectWidth = pageRect?.width ?? 0; + const rectHeight = pageRect?.height ?? 0; + + const aspectRatio: number | null = + rectWidth > 0 ? rectHeight / rectWidth : metadataAspectRatio ?? null; + + let renderedHeight: number | null = rectHeight > 0 ? rectHeight : null; + + if (!renderedHeight || renderedHeight <= 0) { + if (aspectRatio == null || aspectRatio <= 0) { + return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) }; + } + + const pageWidth = viewportWidth / (fitWidthZoom * pagesPerSpread); + const pageHeight = pageWidth * aspectRatio; + renderedHeight = pageHeight * fitWidthZoom; + } + + if (!renderedHeight || renderedHeight <= 0) { + return { type: 'fitWidth' }; + } + + const isLandscape = aspectRatio !== null && aspectRatio < 1; + const targetVisibility = isLandscape ? 100 : visibilityThreshold; + + const visiblePercent = (viewportHeight / renderedHeight) * 100; + + if (visiblePercent >= targetVisibility) { + return { type: 'fitWidth' }; + } + + const allowableHeightRatio = targetVisibility / 100; + const zoomScale = + viewportHeight / (allowableHeightRatio * renderedHeight); + const targetZoom = Math.min(fitWidthZoom, fitWidthZoom * zoomScale); + + if (Math.abs(targetZoom - fitWidthZoom) < 0.001) { + return { type: 'fitWidth' }; + } + + return { type: 'adjust', zoom: targetZoom }; +} + +export interface MeasurePageRectOptions { + selector?: string; + maxAttempts?: number; + shouldCancel?: () => boolean; +} + +export async function measureRenderedPageRect({ + selector = '[data-page-index="0"]', + maxAttempts = 12, + shouldCancel, +}: MeasurePageRectOptions = {}): Promise { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return null; + } + + let rafId: number | null = null; + + const waitForNextFrame = () => + new Promise((resolve) => { + rafId = window.requestAnimationFrame(() => { + rafId = null; + resolve(); + }); + }); + + try { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (shouldCancel?.()) { + return null; + } + + const element = document.querySelector(selector) as HTMLElement | null; + + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return rect; + } + } + + await waitForNextFrame(); + } + } finally { + if (rafId !== null) { + window.cancelAnimationFrame(rafId); + } + } + + return null; +} + +export interface FitWidthResizeOptions { + isManaged: boolean; + requestFitWidth: () => void; + onDebouncedResize: () => void; + debounceMs?: number; +} + +export function useFitWidthResize({ + isManaged, + requestFitWidth, + onDebouncedResize, + debounceMs = 150, +}: FitWidthResizeOptions): void { + const managedRef = useRef(isManaged); + const requestFitWidthRef = useRef(requestFitWidth); + const onDebouncedResizeRef = useRef(onDebouncedResize); + + useEffect(() => { + managedRef.current = isManaged; + }, [isManaged]); + + useEffect(() => { + requestFitWidthRef.current = requestFitWidth; + }, [requestFitWidth]); + + useEffect(() => { + onDebouncedResizeRef.current = onDebouncedResize; + }, [onDebouncedResize]); + + useEffect(() => { + let timeoutId: number | undefined; + + const handleResize = () => { + if (!managedRef.current) { + return; + } + + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + requestFitWidthRef.current?.(); + onDebouncedResizeRef.current?.(); + }, debounceMs); + }; + + window.addEventListener('resize', handleResize); + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + window.removeEventListener('resize', handleResize); + }; + }, [debounceMs]); +} From 4d349c047b907bc3171d3d123d9368d43e8f7e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Tue, 11 Nov 2025 00:41:26 +0100 Subject: [PATCH 03/16] [V2] feat(delete-form,modify-form,fill-form,extract-forms): add delete, modify, fill, and extract form functionality (#4830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR - Adds `/api/v1/form/fields`, `/fill`, `/modify-fields`, and `/delete-fields` endpoints for end-to-end AcroForm workflows. - Centralizes form field detection, filling, modification, and deletion logic in `FormUtils` with strict type handling. - Introduces `FormPayloadParser` for resilient JSON parsing across legacy flat payloads and new structured payloads. - Reuses and extends `FormCopyUtils` plus `FormFieldTypeSupport` to create, clone, and normalize widget properties when transforming forms. ### Implementation Details - `FormFillController` updates the new multipart APIs, and streams updated documents or metadata responses. - `FormUtils` now owns extraction, template building, value application (including flattening strategies), and field CRUD helpers used by the controller endpoints. - `FormPayloadParser` normalizes request bodies: accepts flat key/value maps, combined `fields` arrays, or nested templates, returning deterministic LinkedHashMap ordering for repeatable fills. - `FormFieldTypeSupport` encapsulates per-type creation, value copying, default appearance, and option handling; utilized by both modification flows and `FormCopyUtils` transformations. - `FormCopyUtils` exposes shared routines for making widgets across documents ### API Surface (Multipart Form Data) - `POST /api/v1/form/fields` -> returns `FormUtils.FormFieldExtraction` with ordered `FormFieldInfo` records plus a fill template. - `POST /api/v1/form/fill` -> applies parsed values via `FormUtils.applyFieldValues`; optional `flatten` renders appearances while respecting strict validation. - `POST /api/v1/form/modify-fields` -> updates existing fields in-place using `FormUtils.modifyFormFields` with definitions parsed from `updates` payloads. - `POST /api/v1/form/delete-fields` -> removes named fields after `FormPayloadParser.parseNameList` deduplication and validation. image ### Individual endpoints: image image image image ### Data Validation & Type Safety - Field type inference (`detectFieldType`) and choice option resolution ensure only supported values are written; checkbox mapping uses export states and boolean heuristics. - Choice inputs pass through `filterChoiceSelections` / `filterSingleChoiceSelection` to reject invalid entries and provide actionable logs. - Text fills leverage `setTextValue` to merge inline formatting resources and regenerate appearances when necessary. - `applyFieldValues` supports strict mode (default) to raise when unknown fields are supplied, preventing silent data loss. ### Automation Workflow Support The `/fill` and `/fields` endpoints are designed to work together for automated form processing. The workflow is straightforward: extract the form structure, modify the values, and submit for filling. How It Works: 1. The `/fields` endpoint extracts all form field metadata from your PDF 2. You modify the returned JSON to set the desired values for each field 3. The `/fill` endpoint accepts this same JSON structure to populate the form Example Workflow: ```bash # Step 1: Extract form structure and save to fields.json curl -o fields.json \ -F file=@Form.pdf \ http://localhost:8080/api/v1/form/fields # Step 2: Edit fields.json to update the "value" property for each field # (Use your preferred text editor or script to modify the values) # Step 3: Fill the form using the modified JSON curl -o filled-form.pdf \ -F file=@Form.pdf \ -F data=@fields.json \ http://localhost:8080/api/v1/form/fill ``` #### How to Fill the `template` JSON The `template` (your data) is filled by creating key-value pairs that match the "rules" defined in the `fields` array (the schema). 1. Find the Field `name`: Look in the `fields` array for the `name` of the field you want to fill. * *Example:* `{"name": "Agent of Dependent", "type": "text", ...}` 2. Use `name` as the Key: This `name` becomes the key (in quotes) in your `template` object. * *Example:* `{"Agent of Dependent": ...}` 3. Find the `type`: Look at the `type` for that same field. This tells you what *kind* of value to provide. * `"type": "text"` requires a string (e.g., `"John Smith"`). * `"type": "checkbox"` requires a boolean (e.g., `true` or `false`). * `"type": "combobox"` requires a string that *exactly matches* one of its `"options"` (e.g., `"Choice 1"`). 4. Add the Value: This matching value becomes the value for your key. #### Correct Examples * For a Textbox: * Schema: `{"name": "Agent of Dependent", "type": "text", ...}` * Template: `{"Agent of Dependent": "Mary Jane"}` * For a Checkbox: * Schema: `{"name": "Option 2", "type": "checkbox", ...}` * Template: `{"Option 2": true}` * For a Dropdown (Combobox): * Schema: `{"name": "Dropdown2", "type": "combobox", "options": ["Choice 1", "Choice 2", ...] ...}` * Template: `{"Dropdown2": "Choice 1"}` ### Incorrect Examples (These Will Error) * Wrong Type: `{"Option 2": "Checked"}` * Error: "Option 2" is a `checkbox` and expects `true` or `false`, not a string. * Wrong Option: `{"Dropdown2": "Choice 99"}` * Error: `"Choice 99"` is not listed in the `options` for "Dropdown2". ### For people manually doing this For users filling forms manually, there's a simplified format that focuses only on field names and values: ```json { "FullName": "", "ID": "", "Gender": "Off", "Married": false, "City": "[]" } ``` This format is easier to work with when you're manually editing the JSON. You can skip the full metadata structure (type, label, required, etc.) and just provide the field names with their values. Important caveat: Even though the type information isn't visible in this simplified format, type validation is still enforced by PDF viewers. This simplified format just makes manual editing more convenient while maintaining data integrity. Please note: this suffers from: https://issues.apache.org/jira/browse/PDFBOX-5962 Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/237 Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/3569 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../software/common/util/FormUtils.java | 658 ------ .../common/util/RegexPatternUtils.java | 49 + .../SPDF/config/EndpointConfiguration.java | 6 + .../api/MultiPageLayoutController.java | 21 - .../api/form/FormFillController.java | 215 ++ .../api/form/FormPayloadParser.java | 295 +++ .../filter/UserAuthenticationFilter.java | 3 +- .../proprietary/util/FormCopyUtils.java | 345 ++++ .../util/FormFieldTypeSupport.java | 368 ++++ .../software/proprietary/util/FormUtils.java | 1762 +++++++++++++++++ .../proprietary/util/FormUtilsTest.java | 114 ++ 11 files changed, 3156 insertions(+), 680 deletions(-) delete mode 100644 app/common/src/main/java/stirling/software/common/util/FormUtils.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java deleted file mode 100644 index 19cda95ed8..0000000000 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ /dev/null @@ -1,658 +0,0 @@ -package stirling.software.common.util; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDDocumentCatalog; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.font.PDType1Font; -import org.apache.pdfbox.pdmodel.font.Standard14Fonts; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; -import org.apache.pdfbox.pdmodel.interactive.form.*; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public final class FormUtils { - - private FormUtils() {} - - public static boolean hasAnyRotatedPage(PDDocument document) { - try { - for (PDPage page : document.getPages()) { - int rot = page.getRotation(); - int norm = ((rot % 360) + 360) % 360; - if (norm != 0) { - return true; - } - } - } catch (Exception e) { - log.warn("Failed to inspect page rotations: {}", e.getMessage(), e); - } - return false; - } - - public static void copyAndTransformFormFields( - PDDocument sourceDocument, - PDDocument newDocument, - int totalPages, - int pagesPerSheet, - int cols, - int rows, - float cellWidth, - float cellHeight) - throws IOException { - - PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog(); - PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm(); - - if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) { - return; - } - - PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog(); - PDAcroForm newAcroForm = new PDAcroForm(newDocument); - newCatalog.setAcroForm(newAcroForm); - - PDResources dr = new PDResources(); - PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); - PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS); - dr.put(COSName.getPDFName("Helv"), helvetica); - dr.put(COSName.getPDFName("ZaDb"), zapfDingbats); - newAcroForm.setDefaultResources(dr); - newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); - - // Do not mutate the source AcroForm; skip bad widgets during copy - newAcroForm.setNeedAppearances(true); - - Map fieldNameCounters = new HashMap<>(); - - // Build widget -> field map once for efficient lookups - Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm); - - for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { - PDPage sourcePage = sourceDocument.getPage(pageIndex); - List annotations = sourcePage.getAnnotations(); - - if (annotations.isEmpty()) { - continue; - } - - int destinationPageIndex = pageIndex / pagesPerSheet; - int adjustedPageIndex = pageIndex % pagesPerSheet; - int rowIndex = adjustedPageIndex / cols; - int colIndex = adjustedPageIndex % cols; - - if (destinationPageIndex >= newDocument.getNumberOfPages()) { - continue; - } - - PDPage destinationPage = newDocument.getPage(destinationPageIndex); - PDRectangle sourceRect = sourcePage.getMediaBox(); - - float scaleWidth = cellWidth / sourceRect.getWidth(); - float scaleHeight = cellHeight / sourceRect.getHeight(); - float scale = Math.min(scaleWidth, scaleHeight); - - float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2; - float y = - destinationPage.getMediaBox().getHeight() - - ((rowIndex + 1) * cellHeight - - (cellHeight - sourceRect.getHeight() * scale) / 2); - - copyBasicFormFields( - sourceAcroForm, - newAcroForm, - sourcePage, - destinationPage, - x, - y, - scale, - pageIndex, - fieldNameCounters, - widgetFieldMap); - } - - // Refresh appearances to ensure widgets render correctly across viewers - try { - // Use reflection to avoid compile-time dependency on PDFBox version - Method m = newAcroForm.getClass().getMethod("refreshAppearances"); - m.invoke(newAcroForm); - } catch (NoSuchMethodException nsme) { - log.warn( - "AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances."); - } catch (Throwable t) { - log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t); - } - } - - private static void copyBasicFormFields( - PDAcroForm sourceAcroForm, - PDAcroForm newAcroForm, - PDPage sourcePage, - PDPage destinationPage, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters, - Map widgetFieldMap) { - - try { - List sourceAnnotations = sourcePage.getAnnotations(); - List destinationAnnotations = destinationPage.getAnnotations(); - - for (PDAnnotation annotation : sourceAnnotations) { - if (annotation instanceof PDAnnotationWidget widgetAnnotation) { - if (widgetAnnotation.getRectangle() == null) { - continue; - } - PDField sourceField = - widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null; - if (sourceField == null) { - continue; // skip widgets without a matching field - } - if (sourceField instanceof PDTextField pdtextfield) { - createSimpleTextField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdtextfield, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDCheckBox pdCheckBox) { - createSimpleCheckBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdCheckBox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDRadioButton pdRadioButton) { - createSimpleRadioButtonField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdRadioButton, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDComboBox pdComboBox) { - createSimpleComboBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdComboBox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDListBox pdlistbox) { - createSimpleListBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdlistbox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDSignatureField pdSignatureField) { - createSimpleSignatureField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdSignatureField, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDPushButton pdPushButton) { - createSimplePushButtonField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdPushButton, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } - } - } - } catch (Exception e) { - log.warn( - "Failed to copy basic form fields for page {}: {}", - pageIndex, - e.getMessage(), - e); - } - } - - private static void createSimpleTextField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDTextField sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDTextField newTextField = new PDTextField(newAcroForm); - newTextField.setDefaultAppearance("/Helv 12 Tf 0 g"); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newTextField, - sourceField.getPartialName(), - "textField", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getValueAsString() != null) { - newTextField.setValue(sourceField.getValueAsString()); - } - - } catch (Exception e) { - log.warn( - "Failed to create text field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleCheckBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDCheckBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDCheckBox newCheckBox = new PDCheckBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newCheckBox, - sourceField.getPartialName(), - "checkBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.isChecked()) { - newCheckBox.check(); - } else { - newCheckBox.unCheck(); - } - - } catch (Exception e) { - log.warn( - "Failed to create checkbox field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleRadioButtonField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDRadioButton sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDRadioButton newRadioButton = new PDRadioButton(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newRadioButton, - sourceField.getPartialName(), - "radioButton", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getExportValues() != null) { - newRadioButton.setExportValues(sourceField.getExportValues()); - } - if (sourceField.getValue() != null) { - newRadioButton.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create radio button field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleComboBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDComboBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDComboBox newComboBox = new PDComboBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newComboBox, - sourceField.getPartialName(), - "comboBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getOptions() != null) { - newComboBox.setOptions(sourceField.getOptions()); - } - if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { - newComboBox.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create combo box field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleListBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDListBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDListBox newListBox = new PDListBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newListBox, - sourceField.getPartialName(), - "listBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getOptions() != null) { - newListBox.setOptions(sourceField.getOptions()); - } - if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { - newListBox.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create list box field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleSignatureField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDSignatureField sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDSignatureField newSignatureField = new PDSignatureField(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newSignatureField, - sourceField.getPartialName(), - "signature", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - } catch (Exception e) { - log.warn( - "Failed to create signature field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimplePushButtonField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDPushButton sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDPushButton newPushButton = new PDPushButton(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newPushButton, - sourceField.getPartialName(), - "pushButton", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - } catch (Exception e) { - log.warn( - "Failed to create push button field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static boolean initializeFieldWithWidget( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - T newField, - String originalName, - String fallbackName, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - String baseName = (originalName != null) ? originalName : fallbackName; - String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters); - newField.setPartialName(newFieldName); - - PDAnnotationWidget newWidget = new PDAnnotationWidget(); - PDRectangle sourceRect = sourceWidget.getRectangle(); - if (sourceRect == null) { - return false; - } - - float newX = (sourceRect.getLowerLeftX() * scale) + offsetX; - float newY = (sourceRect.getLowerLeftY() * scale) + offsetY; - float newWidth = sourceRect.getWidth() * scale; - float newHeight = sourceRect.getHeight() * scale; - newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight)); - newWidget.setPage(destinationPage); - - newField.getWidgets().add(newWidget); - newWidget.setParent(newField); - newAcroForm.getFields().add(newField); - destinationAnnotations.add(newWidget); - return true; - } - - private static String generateUniqueFieldName( - String originalName, int pageIndex, Map fieldNameCounters) { - String baseName = "page" + pageIndex + "_" + originalName; - - Integer counter = fieldNameCounters.get(baseName); - if (counter == null) { - counter = 0; - } else { - counter++; - } - fieldNameCounters.put(baseName, counter); - - return counter == 0 ? baseName : baseName + "_" + counter; - } - - private static Map buildWidgetFieldMap(PDAcroForm acroForm) { - Map map = new HashMap<>(); - if (acroForm == null) { - return map; - } - try { - for (PDField field : acroForm.getFieldTree()) { - List widgets = field.getWidgets(); - if (widgets != null) { - for (PDAnnotationWidget w : widgets) { - if (w != null) { - map.put(w, field); - } - } - } - } - } catch (Exception e) { - log.warn("Failed to build widget->field map: {}", e.getMessage(), e); - } - return map; - } -} diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index 8858c99bff..778e42ca47 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -1,5 +1,6 @@ package stirling.software.common.util; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -241,6 +242,11 @@ public final class RegexPatternUtils { return getPattern("\\s+"); } + /** Pattern for matching punctuation characters */ + public Pattern getPunctuationPattern() { + return getPattern("[\\p{Punct}]+"); + } + /** Pattern for matching newlines (Windows and Unix style) */ public Pattern getNewlinesPattern() { return getPattern("\\r?\\n"); @@ -286,6 +292,24 @@ public final class RegexPatternUtils { return getPattern("[^a-zA-Z0-9 ]"); } + /** Pattern for removing bracketed indices like [0], [Child], etc. in field names */ + public Pattern getFormFieldBracketPattern() { + return getPattern("\\[[^\\]]*\\]"); + } + + /** Pattern that replaces underscores or hyphens with spaces */ + public Pattern getUnderscoreHyphenPattern() { + return getPattern("[-_]+"); + } + + /** + * Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces. + * Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size + */ + public Pattern getCamelCaseBoundaryPattern() { + return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])"); + } + /** Pattern for removing angle brackets */ public Pattern getAngleBracketsPattern() { return getPattern("[<>]"); @@ -335,6 +359,26 @@ public final class RegexPatternUtils { return getPattern("[1-9][0-9]{0,2}"); } + /** + * Pattern for very simple generic field tokens such as "field", "text", "checkbox" with + * optional numeric suffix (e.g. "field 1"). Case-insensitive. + */ + public Pattern getGenericFieldNamePattern() { + return getPattern( + "^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$", + Pattern.CASE_INSENSITIVE); + } + + /** Pattern for short identifiers like t1, f2, a10 etc. */ + public Pattern getSimpleFormFieldPattern() { + return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$"); + } + + /** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */ + public Pattern getOptionalTNumericPattern() { + return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE); + } + /** Pattern for validating mathematical expressions */ public Pattern getMathExpressionPattern() { return getPattern("[0-9n+\\-*/() ]+"); @@ -467,6 +511,11 @@ public final class RegexPatternUtils { return getPattern("/"); } + /** Supported logical types when creating new fields programmatically */ + public Set getSupportedNewFieldTypes() { + return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature"); + } + /** * Pre-compile commonly used patterns for immediate availability. This eliminates first-call * compilation overhead for frequent patterns. diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 2b4fa32d95..0178c25971 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -294,6 +294,12 @@ public class EndpointConfiguration { addEndpointToGroup("Other", "replace-and-invert-color-pdf"); addEndpointToGroup("Other", "multi-tool"); + // Adding form-related endpoints to "Other" group + addEndpointToGroup("Other", "fields"); + addEndpointToGroup("Other", "modify-fields"); + addEndpointToGroup("Other", "delete-fields"); + addEndpointToGroup("Other", "fill"); + // Adding endpoints to "Advance" group addEndpointToGroup("Advance", "adjust-contrast"); addEndpointToGroup("Advance", "compress-pdf"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index 40301c63e9..1cf33e7309 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -137,26 +136,6 @@ public class MultiPageLayoutController { contentStream.close(); - // If any source page is rotated, skip form copying/transformation entirely - boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument); - if (hasRotation) { - log.info("Source document has rotated pages; skipping form field copying."); - } else { - try { - FormUtils.copyAndTransformFormFields( - sourceDocument, - newDocument, - totalPages, - pagesPerSheet, - cols, - rows, - cellWidth, - cellHeight); - } catch (Exception e) { - log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e); - } - } - sourceDocument.close(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java new file mode 100644 index 0000000000..ddc7048bdf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java @@ -0,0 +1,215 @@ +package stirling.software.proprietary.controller.api.form; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.util.FormUtils; + +@RestController +@RequestMapping("/api/v1/form") +@Tag(name = "Forms", description = "PDF form APIs") +@RequiredArgsConstructor +public class FormFillController { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ObjectMapper objectMapper; + + private static ResponseEntity saveDocument(PDDocument document, String baseName) + throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf"); + } + + private static String buildBaseName(MultipartFile file, String suffix) { + String original = Filenames.toSimpleFileName(file.getOriginalFilename()); + if (original == null || original.isBlank()) { + original = "document"; + } + if (!original.toLowerCase().endsWith(".pdf")) { + return original + "_" + suffix; + } + String withoutExtension = original.substring(0, original.length() - 4); + return withoutExtension + "_" + suffix; + } + + private static void requirePdf(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.fileFormatRequired", "{0} must be in PDF format", "file"); + } + } + + private static String decodePart(byte[] payload) { + if (payload == null || payload.length == 0) { + return null; + } + return new String(payload, StandardCharsets.UTF_8); + } + + @PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Inspect PDF form fields", + description = "Returns metadata describing each field in the provided PDF form") + public ResponseEntity listFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file) + throws IOException { + + requirePdf(file); + try (PDDocument document = pdfDocumentFactory.load(file, true)) { + FormUtils.FormFieldExtraction extraction = + FormUtils.extractFieldsWithTemplate(document); + return ResponseEntity.ok(extraction); + } + } + + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Modify existing form fields", + description = + "Updates existing fields in the provided PDF and returns the updated file") + public ResponseEntity modifyFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @RequestPart(value = "updates", required = false) byte[] updatesPayload) + throws IOException { + + String rawUpdates = decodePart(updatesPayload); + List modifications = + FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates); + if (modifications.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "updates payload"); + } + + return processSingleFile( + file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + } + + @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Delete form fields", + description = "Removes the specified fields from the PDF and returns the updated file") + public ResponseEntity deleteFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = + "JSON array of field names or objects with a name property," + + " matching the /fields response format", + example = "[{\"name\":\"Field1\"}]") + @RequestPart(value = "names", required = false) + byte[] namesPayload) + throws IOException { + + String rawNames = decodePart(namesPayload); + List names = FormPayloadParser.parseNameList(objectMapper, rawNames); + if (names.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one value", "names payload"); + } + + return processSingleFile( + file, "updated", document -> FormUtils.deleteFormFields(document, names)); + } + + @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Fill PDF form fields", + description = + "Populates the supplied PDF form using values from the provided JSON payload" + + " and returns the filled PDF") + public ResponseEntity fillForm( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = "JSON object of field-value pairs to apply", + example = "{\"field\":\"value\"}") + @RequestPart(value = "data", required = false) + byte[] valuesPayload, + @RequestParam(value = "flatten", defaultValue = "false") boolean flatten) + throws IOException { + + String rawValues = decodePart(valuesPayload); + Map values = FormPayloadParser.parseValueMap(objectMapper, rawValues); + + return processSingleFile( + file, + "filled", + document -> FormUtils.applyFieldValues(document, values, flatten, true)); + } + + private ResponseEntity processSingleFile( + MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + requirePdf(file); + + String baseName = buildBaseName(file, suffix); + try (PDDocument document = pdfDocumentFactory.load(file)) { + processor.accept(document); + return saveDocument(document, baseName); + } + } + + @FunctionalInterface + private interface DocumentProcessor { + void accept(PDDocument document) throws IOException; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java new file mode 100644 index 0000000000..2efffb21de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java @@ -0,0 +1,295 @@ +package stirling.software.proprietary.controller.api.form; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import stirling.software.common.util.ExceptionUtils; +import stirling.software.proprietary.util.FormUtils; + +final class FormPayloadParser { + + private static final String KEY_FIELDS = "fields"; + private static final String KEY_NAME = "name"; + private static final String KEY_TARGET_NAME = "targetName"; + private static final String KEY_FIELD_NAME = "fieldName"; + private static final String KEY_FIELD = "field"; + private static final String KEY_VALUE = "value"; + private static final String KEY_DEFAULT_VALUE = "defaultValue"; + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> + MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + + private FormPayloadParser() {} + + static Map parseValueMap(ObjectMapper objectMapper, String json) + throws IOException { + if (json == null || json.isBlank()) { + return Map.of(); + } + + JsonNode root; + try { + root = objectMapper.readTree(json); + } catch (IOException e) { + // Fallback to legacy direct map parse (will throw again if invalid) + return objectMapper.readValue(json, MAP_TYPE); + } + if (root == null || root.isNull()) { + return Map.of(); + } + + // 1. If payload already a flat object with no special wrapping, keep legacy behavior + if (root.isObject()) { + // a) Prefer explicit 'template' object if present (new combined /fields response) + JsonNode templateNode = root.get("template"); + if (templateNode != null && templateNode.isObject()) { + return objectToLinkedMap(templateNode); + } + // b) Accept an inline 'fields' array of field definitions (build map from them) + JsonNode fieldsNode = root.get(KEY_FIELDS); + if (fieldsNode != null && fieldsNode.isArray()) { + Map record = extractFieldInfoArray(fieldsNode); + if (!record.isEmpty()) { + return record; + } + } + // c) Fallback: treat entire object as the value map (legacy behavior) + return objectToLinkedMap(root); + } + + // 2. If an array was supplied to /fill (non-standard), treat first element as record + if (root.isArray()) { + if (root.isEmpty()) { + return Map.of(); + } + JsonNode first = root.get(0); + if (first != null && first.isObject()) { + if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) { + return extractFieldInfoArray(root); + } + return objectToLinkedMap(first); + } + return Map.of(); + } + + // 3. Anything else: fallback to strict map parse + return objectMapper.readValue(json, MAP_TYPE); + } + + static List parseModificationDefinitions( + ObjectMapper objectMapper, String json) throws IOException { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); + } + + static List parseNameList(ObjectMapper objectMapper, String json) throws IOException { + if (json == null || json.isBlank()) { + return List.of(); + } + + final JsonNode root = objectMapper.readTree(json); + if (root == null || root.isNull()) { + return List.of(); + } + + final Set names = new LinkedHashSet<>(); + + if (root.isArray()) { + collectNames(root, names); + } else if (root.isObject()) { + if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) { + collectNames(root.get(KEY_FIELDS), names); + } else { + final String single = extractName(root); + if (nonBlank(single)) { + names.add(single); + } + } + } else if (root.isTextual()) { + final String single = trimToNull(root.asText()); + if (single != null) { + names.add(single); + } + } + + if (!names.isEmpty()) { + return List.copyOf(names); + } + + try { + return objectMapper.readValue(json, STRING_LIST_TYPE); + } catch (IOException e) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "names payload", + "expected array of strings or objects with 'name'-like properties"); + } + } + + private static Map extractFieldInfoArray(JsonNode fieldsNode) { + final Map record = new LinkedHashMap<>(); + if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) { + return record; + } + + for (JsonNode fieldNode : fieldsNode) { + if (fieldNode == null || !fieldNode.isObject()) { + continue; + } + + final String name = extractName(fieldNode); + if (!nonBlank(name)) { + continue; + } + + JsonNode valueNode = fieldNode.get(KEY_VALUE); + if ((valueNode == null || valueNode.isNull()) + && fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) { + valueNode = fieldNode.get(KEY_DEFAULT_VALUE); + } + + final String normalized = normalizeFieldValue(valueNode); + record.put(name, normalized == null ? "" : normalized); + } + + return record; + } + + private static String normalizeFieldValue(JsonNode valueNode) { + if (valueNode == null || valueNode.isNull()) { + return null; + } + + if (valueNode.isArray()) { + final List values = new ArrayList<>(); + for (JsonNode element : valueNode) { + final String text = coerceScalarToString(element); + if (text != null) { + values.add(text); + } + } + return String.join(",", values); + } + + if (valueNode.isObject()) { + // Preserve object as JSON string + return valueNode.toString(); + } + + // Scalar (text/number/boolean) + return coerceScalarToString(valueNode); + } + + private static String coerceScalarToString(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isTextual()) { + return trimToEmpty(node.asText()); + } + if (node.isNumber()) { + return node.numberValue().toString(); + } + if (node.isBoolean()) { + return Boolean.toString(node.booleanValue()); + } + // Fallback for other scalar-like nodes + return trimToEmpty(node.asText()); + } + + private static void collectNames(JsonNode arrayNode, Set sink) { + if (arrayNode == null || !arrayNode.isArray()) { + return; + } + for (JsonNode node : arrayNode) { + final String name = extractName(node); + if (nonBlank(name)) { + sink.add(name); + } + } + } + + private static String extractName(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + + if (node.isTextual()) { + return trimToNull(node.asText()); + } + + if (node.isObject()) { + final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME); + if (nonBlank(direct)) { + return direct; + } + final JsonNode field = node.get(KEY_FIELD); + if (field != null && field.isObject()) { + final String nested = + textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME); + if (nonBlank(nested)) { + return nested; + } + } + } + + return null; + } + + private static String textProperty(JsonNode node, String... keys) { + for (String key : keys) { + final JsonNode valueNode = node.get(key); + final String value = coerceScalarToString(valueNode); + if (nonBlank(value)) { + return value; + } + } + return null; + } + + private static Map objectToLinkedMap(JsonNode objectNode) { + final Map result = new LinkedHashMap<>(); + objectNode + .fieldNames() + .forEachRemaining( + key -> { + final JsonNode v = objectNode.get(key); + if (v == null || v.isNull()) { + result.put(key, null); + } else if (v.isTextual() || v.isNumber() || v.isBoolean()) { + result.put(key, coerceScalarToString(v)); + } else { + result.put(key, v.toString()); + } + }); + return result; + } + + private static boolean nonBlank(String s) { + return s != null && !s.isBlank(); + } + + private static String trimToNull(String s) { + if (s == null) return null; + final String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static String trimToEmpty(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index b36dc36cc2..cdf7bdd0d5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -107,7 +107,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } } - // If we still don't have any authentication, check if it's a public endpoint. If not, deny the request + // If we still don't have any authentication, check if it's a public endpoint. If not, deny + // the request if (authentication == null || !authentication.isAuthenticated()) { String method = request.getMethod(); String contextPath = request.getContextPath(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java new file mode 100644 index 0000000000..c27a2ab2ba --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java @@ -0,0 +1,345 @@ +package stirling.software.proprietary.util; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class FormCopyUtils { + + public boolean hasAnyRotatedPage(PDDocument document) { + try { + for (PDPage page : document.getPages()) { + int rot = page.getRotation(); + int norm = ((rot % 360) + 360) % 360; + if (norm != 0) { + return true; + } + } + } catch (Exception e) { + log.warn("Failed to inspect page rotations: {}", e.getMessage(), e); + } + return false; + } + + public void copyAndTransformFormFields( + PDDocument sourceDocument, + PDDocument newDocument, + int totalPages, + int pagesPerSheet, + int cols, + int rows, + float cellWidth, + float cellHeight) + throws IOException { + + PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog(); + PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm(); + + if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) { + return; + } + + PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog(); + PDAcroForm newAcroForm = new PDAcroForm(newDocument); + newCatalog.setAcroForm(newAcroForm); + + PDResources dr = new PDResources(); + PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS); + dr.put(COSName.getPDFName("Helv"), helvetica); + dr.put(COSName.getPDFName("ZaDb"), zapfDingbats); + newAcroForm.setDefaultResources(dr); + newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + + // Temporarily set NeedAppearances to true during field creation + newAcroForm.setNeedAppearances(true); + + Map fieldNameCounters = new HashMap<>(); + + // Build widget -> field map once for efficient lookups + Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm); + + for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { + PDPage sourcePage = sourceDocument.getPage(pageIndex); + List annotations = sourcePage.getAnnotations(); + + if (annotations.isEmpty()) { + continue; + } + + int destinationPageIndex = pageIndex / pagesPerSheet; + int adjustedPageIndex = pageIndex % pagesPerSheet; + int rowIndex = adjustedPageIndex / cols; + int colIndex = adjustedPageIndex % cols; + + if (rowIndex >= rows) { + continue; + } + + if (destinationPageIndex >= newDocument.getNumberOfPages()) { + continue; + } + + PDPage destinationPage = newDocument.getPage(destinationPageIndex); + PDRectangle sourceRect = sourcePage.getMediaBox(); + + float scaleWidth = cellWidth / sourceRect.getWidth(); + float scaleHeight = cellHeight / sourceRect.getHeight(); + float scale = Math.min(scaleWidth, scaleHeight); + + float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2; + float y = + destinationPage.getMediaBox().getHeight() + - ((rowIndex + 1) * cellHeight + - (cellHeight - sourceRect.getHeight() * scale) / 2); + + copyBasicFormFields( + sourceAcroForm, + newAcroForm, + sourcePage, + destinationPage, + x, + y, + scale, + pageIndex, + fieldNameCounters, + widgetFieldMap); + } + + // Generate appearance streams and embed them authoritatively + boolean appearancesGenerated = false; + try { + newAcroForm.refreshAppearances(); + appearancesGenerated = true; + } catch (NoSuchMethodError nsme) { + log.warn( + "AcroForm.refreshAppearances() not available in this PDFBox version; " + + "leaving NeedAppearances=true for viewer-side rendering."); + } catch (Exception t) { + log.warn( + "Failed to refresh field appearances via AcroForm: {}. " + + "Leaving NeedAppearances=true as fallback.", + t.getMessage(), + t); + } + + // After successful appearance generation, set NeedAppearances to false + // to signal that appearance streams are now embedded authoritatively + if (appearancesGenerated) { + try { + newAcroForm.setNeedAppearances(false); + } catch (Exception e) { + log.debug( + "Failed to set NeedAppearances to false: {}. " + + "Appearances were generated but flag could not be updated.", + e.getMessage()); + } + } + } + + private void copyBasicFormFields( + PDAcroForm sourceAcroForm, + PDAcroForm newAcroForm, + PDPage sourcePage, + PDPage destinationPage, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters, + Map widgetFieldMap) { + + try { + List sourceAnnotations = sourcePage.getAnnotations(); + List destinationAnnotations = destinationPage.getAnnotations(); + + for (PDAnnotation annotation : sourceAnnotations) { + if (annotation instanceof PDAnnotationWidget widgetAnnotation) { + if (widgetAnnotation.getRectangle() == null) { + continue; + } + PDField sourceField = + widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null; + if (sourceField == null) { + continue; // skip widgets without a matching field + } + if (!(sourceField instanceof PDTerminalField terminalField)) { + continue; + } + + FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField); + if (handler == null) { + log.debug( + "Skipping unsupported field type '{}' for widget '{}'", + sourceField.getClass().getSimpleName(), + Optional.ofNullable(sourceField.getFullyQualifiedName()) + .orElseGet(sourceField::getPartialName)); + continue; + } + + copyFieldUsingHandler( + handler, + terminalField, + newAcroForm, + destinationPage, + destinationAnnotations, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } + } + } catch (Exception e) { + log.warn( + "Failed to copy basic form fields for page {}: {}", + pageIndex, + e.getMessage(), + e); + } + } + + private void copyFieldUsingHandler( + FormFieldTypeSupport handler, + PDTerminalField sourceField, + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDTerminalField newField = handler.createField(newAcroForm); + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newField, + sourceField.getPartialName(), + handler.fallbackWidgetName(), + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + handler.copyFromOriginal(sourceField, newField); + } catch (Exception e) { + log.warn( + "Failed to copy {} field '{}': {}", + handler.typeName(), + Optional.ofNullable(sourceField.getFullyQualifiedName()) + .orElseGet(sourceField::getPartialName), + e.getMessage(), + e); + } + } + + private boolean initializeFieldWithWidget( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + T newField, + String originalName, + String fallbackName, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + String baseName = (originalName != null) ? originalName : fallbackName; + String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters); + newField.setPartialName(newFieldName); + + PDAnnotationWidget newWidget = new PDAnnotationWidget(); + PDRectangle sourceRect = sourceWidget.getRectangle(); + if (sourceRect == null) { + return false; + } + + float newX = (sourceRect.getLowerLeftX() * scale) + offsetX; + float newY = (sourceRect.getLowerLeftY() * scale) + offsetY; + float newWidth = sourceRect.getWidth() * scale; + float newHeight = sourceRect.getHeight() * scale; + newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight)); + newWidget.setPage(destinationPage); + + newField.getWidgets().add(newWidget); + newWidget.setParent(newField); + newAcroForm.getFields().add(newField); + destinationAnnotations.add(newWidget); + return true; + } + + private String generateUniqueFieldName( + String originalName, int pageIndex, Map fieldNameCounters) { + String baseName = "page" + pageIndex + "_" + originalName; + + Integer counter = fieldNameCounters.get(baseName); + if (counter == null) { + counter = 0; + } else { + counter++; + } + fieldNameCounters.put(baseName, counter); + + return counter == 0 ? baseName : baseName + "_" + counter; + } + + private Map buildWidgetFieldMap(PDAcroForm acroForm) { + Map map = new HashMap<>(); + if (acroForm == null) { + return map; + } + try { + for (PDField field : acroForm.getFieldTree()) { + List widgets = field.getWidgets(); + if (widgets == null) { + continue; + } + for (PDAnnotationWidget widget : widgets) { + if (widget != null) { + map.put(widget, field); + } + } + } + } catch (Exception e) { + log.warn("Failed to build widget->field map: {}", e.getMessage(), e); + } + return map; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java new file mode 100644 index 0000000000..4f1c1e0d8c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java @@ -0,0 +1,368 @@ +package stirling.software.proprietary.util; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; +import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDChoice; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDListBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public enum FormFieldTypeSupport { + TEXT("text", "textField", PDTextField.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + PDTextField textField = new PDTextField(acroForm); + textField.setDefaultAppearance("/Helv 12 Tf 0 g"); + return textField; + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDTextField src = (PDTextField) source; + PDTextField dst = (PDTextField) target; + String value = src.getValueAsString(); + if (value != null) { + dst.setValue(value); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDTextField textField = (PDTextField) field; + String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); + if (!defaultValue.isBlank()) { + FormUtils.setTextValue(textField, defaultValue); + } + } + }, + CHECKBOX("checkbox", "checkBox", PDCheckBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDCheckBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDCheckBox src = (PDCheckBox) source; + PDCheckBox dst = (PDCheckBox) target; + if (src.isChecked()) { + dst.check(); + } else { + dst.unCheck(); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDCheckBox checkBox = (PDCheckBox) field; + + if (!options.isEmpty()) { + checkBox.setExportValues(options); + } + + ensureCheckBoxAppearance(checkBox); + + if (FormUtils.isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + } + + private static void ensureCheckBoxAppearance(PDCheckBox checkBox) { + try { + if (checkBox.getWidgets().isEmpty()) { + return; + } + + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + + PDAppearanceCharacteristicsDictionary appearanceChars = + widget.getAppearanceCharacteristics(); + if (appearanceChars == null) { + appearanceChars = + new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(appearanceChars); + } + + appearanceChars.setBorderColour( + new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE)); + appearanceChars.setBackground( + new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE)); + + appearanceChars.setNormalCaption("4"); + + widget.setPrinted(true); + widget.setReadOnly(false); + widget.setHidden(false); + + } catch (Exception e) { + log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage()); + } + } + }, + RADIO("radio", "radioButton", PDRadioButton.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDRadioButton(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDRadioButton src = (PDRadioButton) source; + PDRadioButton dst = (PDRadioButton) target; + if (src.getExportValues() != null) { + dst.setExportValues(src.getExportValues()); + } + if (src.getValue() != null) { + dst.setValue(src.getValue()); + } + } + }, + COMBOBOX("combobox", "comboBox", PDComboBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDComboBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDComboBox src = (PDComboBox) source; + PDComboBox dst = (PDComboBox) target; + copyChoiceCharacteristics(src, dst); + if (src.getOptions() != null) { + dst.setOptions(src.getOptions()); + } + if (src.getValue() != null && !src.getValue().isEmpty()) { + dst.setValue(src.getValue()); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDComboBox comboBox = (PDComboBox) field; + if (!options.isEmpty()) { + comboBox.setOptions(options); + } + List allowedOptions = FormUtils.resolveOptions(comboBox); + String comboName = + Optional.ofNullable(comboBox.getFullyQualifiedName()) + .orElseGet(comboBox::getPartialName); + String defaultValue = definition.defaultValue(); + if (defaultValue != null && !defaultValue.isBlank()) { + String filtered = + FormUtils.filterSingleChoiceSelection( + defaultValue, allowedOptions, comboName); + if (filtered != null) { + comboBox.setValue(filtered); + } + } + } + }, + LISTBOX("listbox", "listBox", PDListBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDListBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDListBox src = (PDListBox) source; + PDListBox dst = (PDListBox) target; + copyChoiceCharacteristics(src, dst); + if (src.getOptions() != null) { + dst.setOptions(src.getOptions()); + } + if (src.getValue() != null && !src.getValue().isEmpty()) { + dst.setValue(src.getValue()); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDListBox listBox = (PDListBox) field; + listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect())); + if (!options.isEmpty()) { + listBox.setOptions(options); + } + List allowedOptions = FormUtils.collectChoiceAllowedValues(listBox); + String listBoxName = + Optional.ofNullable(listBox.getFullyQualifiedName()) + .orElseGet(listBox::getPartialName); + String defaultValue = definition.defaultValue(); + if (defaultValue != null && !defaultValue.isBlank()) { + if (Boolean.TRUE.equals(definition.multiSelect())) { + List selections = FormUtils.parseMultiChoiceSelections(defaultValue); + List filtered = + FormUtils.filterChoiceSelections( + selections, allowedOptions, listBoxName); + if (!filtered.isEmpty()) { + listBox.setValue(filtered); + } + } else { + String filtered = + FormUtils.filterSingleChoiceSelection( + defaultValue, allowedOptions, listBoxName); + if (filtered != null) { + listBox.setValue(filtered); + } + } + } + } + }, + SIGNATURE("signature", "signature", PDSignatureField.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDSignatureField(acroForm); + } + }, + BUTTON("button", "pushButton", PDPushButton.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDPushButton(acroForm); + } + }; + + private static final Map BY_TYPE = + Arrays.stream(values()) + .collect( + Collectors.toUnmodifiableMap( + FormFieldTypeSupport::typeName, Function.identity())); + + private final String typeName; + private final String fallbackWidgetName; + private final Class fieldClass; + + FormFieldTypeSupport( + String typeName, + String fallbackWidgetName, + Class fieldClass) { + this.typeName = typeName; + this.fallbackWidgetName = fallbackWidgetName; + this.fieldClass = fieldClass; + } + + public static FormFieldTypeSupport forField(PDField field) { + if (field == null) { + return null; + } + for (FormFieldTypeSupport handler : values()) { + if (handler.fieldClass.isInstance(field)) { + return handler; + } + } + return null; + } + + public static FormFieldTypeSupport forTypeName(String typeName) { + if (typeName == null) { + return null; + } + return BY_TYPE.get(typeName); + } + + private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) { + if (sourceField == null || targetField == null) { + return; + } + + try { + int flags = sourceField.getCOSObject().getInt(COSName.FF); + targetField.getCOSObject().setInt(COSName.FF, flags); + } catch (Exception e) { + // ignore and continue + } + + if (sourceField instanceof PDListBox sourceList + && targetField instanceof PDListBox targetList) { + try { + targetList.setMultiSelect(sourceList.isMultiSelect()); + } catch (Exception ignored) { + // ignore + } + } + } + + String typeName() { + return typeName; + } + + String fallbackWidgetName() { + return fallbackWidgetName; + } + + abstract PDTerminalField createField(PDAcroForm acroForm); + + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + // default no-op + } + + boolean doesNotsupportsDefinitionCreation() { + return true; + } + + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + // default no-op + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java new file mode 100644 index 0000000000..f35a3c3080 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java @@ -0,0 +1,1762 @@ +package stirling.software.proprietary.util; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; +import org.apache.pdfbox.pdmodel.interactive.form.*; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.RegexPatternUtils; + +@Slf4j +@UtilityClass +public class FormUtils { + + // Field type constants + public final String FIELD_TYPE_TEXT = "text"; + public final String FIELD_TYPE_CHECKBOX = "checkbox"; + public final String FIELD_TYPE_COMBOBOX = "combobox"; + public final String FIELD_TYPE_LISTBOX = "listbox"; + public final String FIELD_TYPE_RADIO = "radio"; + public final String FIELD_TYPE_BUTTON = "button"; + public final String FIELD_TYPE_SIGNATURE = "signature"; + + // Set of choice field types that support options + public final Set CHOICE_FIELD_TYPES = + Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + + /** + * Returns a normalized logical type string for the supplied PDFBox field instance. Centralized + * so all callers share identical mapping logic. + * + * @param field PDField to classify + * @return one of: signature, button, text, checkbox, combobox, listbox, radio (defaults to + * text) + */ + public String detectFieldType(PDField field) { + if (field instanceof PDSignatureField) { + return FIELD_TYPE_SIGNATURE; + } + if (field instanceof PDPushButton) { + return FIELD_TYPE_BUTTON; + } + if (field instanceof PDTextField) { + return FIELD_TYPE_TEXT; + } + if (field instanceof PDCheckBox) { + return FIELD_TYPE_CHECKBOX; + } + if (field instanceof PDComboBox) { + return FIELD_TYPE_COMBOBOX; + } + if (field instanceof PDListBox) { + return FIELD_TYPE_LISTBOX; + } + if (field instanceof PDRadioButton) { + return FIELD_TYPE_RADIO; + } + return FIELD_TYPE_TEXT; + } + + public List extractFormFields(PDDocument document) { + if (document == null) return List.of(); + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) return List.of(); + + List fields = new ArrayList<>(); + Map typeCounters = new HashMap<>(); + Map pageOrderCounters = new HashMap<>(); + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + String type = detectFieldType(terminalField); + + String name = + Optional.ofNullable(field.getFullyQualifiedName()) + .orElseGet(field::getPartialName); + if (name == null || name.isBlank()) { + continue; + } + + String currentValue = safeValue(terminalField); + boolean required = field.isRequired(); + int pageIndex = resolveFirstWidgetPageIndex(document, terminalField); + List options = resolveOptions(terminalField); + String tooltip = resolveTooltip(terminalField); + int typeIndex = typeCounters.merge(type, 1, Integer::sum); + String displayLabel = + deriveDisplayLabel(field, name, tooltip, type, typeIndex, options); + boolean multiSelect = resolveMultiSelect(terminalField); + int pageOrder = pageOrderCounters.merge(pageIndex, 1, Integer::sum) - 1; + + fields.add( + new FormFieldInfo( + name, + displayLabel, + type, + currentValue, + options.isEmpty() ? null : Collections.unmodifiableList(options), + required, + pageIndex, + multiSelect, + tooltip, + pageOrder)); + } + + fields.sort( + (a, b) -> { + int pageCompare = Integer.compare(a.pageIndex(), b.pageIndex()); + if (pageCompare != 0) { + return pageCompare; + } + int orderCompare = Integer.compare(a.pageOrder(), b.pageOrder()); + if (orderCompare != 0) { + return orderCompare; + } + return a.name().compareToIgnoreCase(b.name()); + }); + + return Collections.unmodifiableList(fields); + } + + /** + * Build a single record object (field-name -> value placeholder) that can be directly submitted + * to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless + * currently checked. For list/choice fields we default to empty string. For multi-select list + * boxes we return an empty JSON array. Radio buttons get their current value (or empty string). + * Signature and button fields are skipped. + */ + public Map buildFillTemplateRecord(List extracted) { + if (extracted == null || extracted.isEmpty()) return Map.of(); + Map record = new LinkedHashMap<>(); + for (FormFieldInfo info : extracted) { + if (info == null || info.name() == null || info.name().isBlank()) { + continue; + } + String type = info.type(); + Object value; + switch (type) { + case FIELD_TYPE_CHECKBOX: + value = isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE; + break; + case FIELD_TYPE_LISTBOX: + if (info.multiSelect()) { + value = new ArrayList<>(); + } else { + value = safeDefault(info.value()); + } + break; + case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE: + continue; // skip non-fillable + default: + value = safeDefault(info.value()); + } + record.put(info.name(), value); + } + return record; + } + + public FormFieldExtraction extractFieldsWithTemplate(PDDocument document) { + List fields = extractFormFields(document); + Map template = buildFillTemplateRecord(fields); + return new FormFieldExtraction(fields, template); + } + + private String safeDefault(String current) { + return current != null ? current : ""; + } + + public void applyFieldValues( + PDDocument document, Map values, boolean flatten, boolean strict) + throws IOException { + if (document == null) { + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + if (strict) { + throw new IOException("No AcroForm present in document"); + } + log.debug("Skipping form fill because document has no AcroForm"); + if (flatten) { + flattenEntireDocument(document, null); + } + return; + } + + if (values != null && !values.isEmpty()) { + acroForm.setCacheFields(true); + + Map lookup = new LinkedHashMap<>(); + for (PDField field : acroForm.getFieldTree()) { + String fqName = field.getFullyQualifiedName(); + if (fqName != null) { + lookup.putIfAbsent(fqName, field); + } + String partial = field.getPartialName(); + if (partial != null) { + lookup.putIfAbsent(partial, field); + } + } + + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isBlank()) { + continue; + } + + PDField field = lookup.get(key); + if (field == null) { + field = acroForm.getField(key); + } + if (field == null) { + log.debug("No matching field found for '{}', skipping", key); + continue; + } + + Object rawValue = entry.getValue(); + String value = rawValue == null ? null : Objects.toString(rawValue, null); + applyValueToField(field, value, strict); + } + + ensureAppearances(acroForm); + } + + repairWidgetGeometry(document, acroForm); + + if (flatten) { + flattenEntireDocument(document, acroForm); + } + } + + private void flattenViaRendering(PDDocument document, PDAcroForm acroForm) throws IOException { + if (document == null) { + return; + } + + // Remove the AcroForm structure first since we're rendering everything + if (acroForm != null) { + try { + if (document.getDocumentCatalog() != null) { + document.getDocumentCatalog().setAcroForm(null); + } + } catch (Exception e) { + log.debug("Failed to remove AcroForm before rendering: {}", e.getMessage()); + } + } + + PDFRenderer renderer = new PDFRenderer(document); + ApplicationProperties properties = + ApplicationContextProvider.getBean(ApplicationProperties.class); + + int requestedDpi = + properties != null && properties.getSystem() != null + ? properties.getSystem().getMaxDPI() + : 300; + + rebuildDocumentFromImages(document, renderer, requestedDpi); + } + + // note: this implementation suffers from: + // https://issues.apache.org/jira/browse/PDFBOX-5962 + private void flattenEntireDocument(PDDocument document, PDAcroForm acroForm) + throws IOException { + if (document == null) { + return; + } + + flattenViaRendering(document, acroForm); + } + + private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi) + throws IOException { + int pageCount = document.getNumberOfPages(); + + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + BufferedImage rendered; + try { + rendered = renderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); + } catch (OutOfMemoryError e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); + } catch (NegativeArraySizeException e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); + } + + PDPage page = document.getPage(pageIndex); + PDRectangle mediaBox = page.getMediaBox(); + + // Ensure the page has resources before drawing + if (page.getResources() == null) { + page.setResources(new PDResources()); + } + + List annotations = new ArrayList<>(page.getAnnotations()); + for (PDAnnotation annotation : annotations) { + annotation.getCOSObject().removeItem(COSName.AP); + page.getAnnotations().remove(annotation); + } + + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.OVERWRITE, true, true)) { + PDImageXObject pdImage = JPEGFactory.createFromImage(document, rendered); + contentStream.drawImage( + pdImage, + mediaBox.getLowerLeftX(), + mediaBox.getLowerLeftY(), + mediaBox.getWidth(), + mediaBox.getHeight()); + } + } + } + + private void repairWidgetGeometry(PDDocument document, PDAcroForm acroForm) { + if (document == null || acroForm == null) { + return; + } + + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + List widgets = terminalField.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + continue; + } + + for (PDAnnotationWidget widget : widgets) { + if (widget == null) { + continue; + } + + PDRectangle rectangle = widget.getRectangle(); + boolean invalidRectangle = + rectangle == null + || rectangle.getWidth() <= 0 + || rectangle.getHeight() <= 0; + + PDPage page = widget.getPage(); + if (page == null) { + page = resolveWidgetPage(document, widget); + if (page != null) { + widget.setPage(page); + } + } + + if (invalidRectangle) { + if (page == null && document.getNumberOfPages() > 0) { + page = document.getPage(0); + widget.setPage(page); + } + + if (page != null) { + PDRectangle mediaBox = page.getMediaBox(); + float fallbackWidth = Math.min(200f, mediaBox.getWidth()); + float fallbackHeight = Math.min(40f, mediaBox.getHeight()); + PDRectangle fallbackRectangle = + new PDRectangle( + mediaBox.getLowerLeftX(), + mediaBox.getLowerLeftY(), + fallbackWidth, + fallbackHeight); + widget.setRectangle(fallbackRectangle); + + try { + List pageAnnotations = page.getAnnotations(); + if (pageAnnotations != null && !pageAnnotations.contains(widget)) { + pageAnnotations.add(widget); + } + } catch (IOException e) { + log.debug( + "Unable to repair annotations for widget '{}': {}", + terminalField.getFullyQualifiedName(), + e.getMessage()); + } + } + } + } + } + } + + public void applyFieldValues(PDDocument document, Map values, boolean flatten) + throws IOException { + applyFieldValues(document, values, flatten, false); + } + + private void ensureAppearances(PDAcroForm acroForm) { + if (acroForm == null) return; + + acroForm.setNeedAppearances(true); + try { + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + try { + // Map standard name used by many DAs + dr.put(COSName.getPDFName("Helvetica"), helvetica); + } catch (Exception ignore) { + try { + dr.add(helvetica); + } catch (Exception ignore2) { + // ignore + } + } + } catch (Exception fontPrep) { + log.debug( + "Unable to ensure default font resources before refresh: {}", + fontPrep.getMessage()); + } + acroForm.refreshAppearances(); + } catch (IOException e) { + log.warn("Failed to refresh form appearances: {}", e.getMessage(), e); + return; // Don't set NeedAppearances to false if refresh failed + } + + // After successful appearance generation, set NeedAppearances to false + // to signal that appearance streams are now embedded authoritatively + try { + acroForm.setNeedAppearances(false); + } catch (Exception ignored) { + // Fallback to direct COS manipulation if the setter fails + acroForm.getCOSObject().setBoolean(COSName.NEED_APPEARANCES, false); + } + } + + private PDAcroForm getAcroFormSafely(PDDocument document) { + try { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + return catalog != null ? catalog.getAcroForm() : null; + } catch (Exception e) { + log.warn("Unable to access AcroForm: {}", e.getMessage(), e); + return null; + } + } + + public String filterSingleChoiceSelection( + String selection, List allowedOptions, String fieldName) { + if (selection == null || selection.trim().isEmpty()) return null; + List filtered = + filterChoiceSelections(List.of(selection), allowedOptions, fieldName); + return filtered.isEmpty() ? null : filtered.get(0); + } + + private void applyValueToField(PDField field, String value, boolean strict) throws IOException { + try { + if (field instanceof PDTextField textField) { + setTextValue(textField, value); + } else if (field instanceof PDCheckBox checkBox) { + LinkedHashSet candidateStates = collectCheckBoxStates(checkBox); + boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates); + try { + if (shouldCheck) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + } catch (IOException checkProblem) { + log.warn( + "Failed to set checkbox state for '{}': {}", + field.getFullyQualifiedName(), + checkProblem.getMessage(), + checkProblem); + if (strict) { + throw checkProblem; + } + } + } else if (field instanceof PDRadioButton radioButton) { + if (value != null && !value.isBlank()) { + radioButton.setValue(value); + } + } else if (field instanceof PDChoice choiceField) { + applyChoiceValue(choiceField, value); + } else if (field instanceof PDPushButton) { + log.debug("Ignore Push button"); + } else if (field instanceof PDSignatureField) { + log.debug("Skipping signature field '{}'", field.getFullyQualifiedName()); + } else { + field.setValue(value != null ? value : ""); + } + } catch (Exception e) { + log.warn( + "Failed to set value for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage(), + e); + if (strict) { + if (e instanceof IOException io) { + throw io; + } + throw new IOException( + "Failed to set value for field '" + field.getFullyQualifiedName() + "'", e); + } + } + } + + void setTextValue(PDTextField textField, String value) throws IOException { + try { + textField.setValue(value != null ? value : ""); + return; + } catch (IOException initial) { + log.debug( + "Primary fill failed for text field '{}': {}", + textField.getFullyQualifiedName(), + initial.getMessage()); + } + + try { + PDAcroForm acroForm = textField.getAcroForm(); + PDResources dr = acroForm != null ? acroForm.getDefaultResources() : null; + if (dr == null && acroForm != null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + + String resourceName = "Helv"; + try { + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + if (dr != null) { + try { + COSName alias = dr.add(helvetica); + if (alias != null + && alias.getName() != null + && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + } catch (Exception addEx) { + try { + COSName explicit = COSName.getPDFName("Helvetica"); + dr.put(explicit, helvetica); + resourceName = explicit.getName(); + } catch (Exception ignore) { + // ignore + } + } + } + } catch (Exception fontEx) { + log.debug( + "Unable to prepare Helvetica font for '{}': {}", + textField.getFullyQualifiedName(), + fontEx.getMessage()); + } + + textField.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } catch (Exception e) { + log.debug( + "Unable to adjust default appearance for '{}': {}", + textField.getFullyQualifiedName(), + e.getMessage()); + } + + textField.setValue(value != null ? value : ""); + } + + private void applyChoiceValue(PDChoice choiceField, String value) throws IOException { + if (value == null) { + choiceField.setValue(""); + return; + } + + List allowedOptions = collectChoiceAllowedValues(choiceField); + + if (choiceField.isMultiSelect()) { + List selections = parseMultiChoiceSelections(value); + List filteredSelections = + filterChoiceSelections( + selections, allowedOptions, choiceField.getFullyQualifiedName()); + if (filteredSelections.isEmpty()) { + choiceField.setValue(Collections.emptyList()); + } else { + choiceField.setValue(filteredSelections); + } + } else { + String selected = + filterSingleChoiceSelection( + value, allowedOptions, choiceField.getFullyQualifiedName()); + choiceField.setValue(Objects.requireNonNullElse(selected, "")); + } + } + + List filterChoiceSelections( + List selections, List allowedOptions, String fieldName) { + if (selections == null || selections.isEmpty()) { + return Collections.emptyList(); + } + + List sanitizedSelections = + selections.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + if (sanitizedSelections.isEmpty()) { + return Collections.emptyList(); + } + + if (allowedOptions == null || allowedOptions.isEmpty()) { + throw new IllegalArgumentException( + "The /Opt array is missing for choice field '" + + fieldName + + "', cannot set values."); + } + + Map allowedLookup = new LinkedHashMap<>(); + for (String option : allowedOptions) { + if (option == null) { + continue; + } + String normalized = option.trim(); + if (!normalized.isEmpty()) { + allowedLookup.putIfAbsent(normalized.toLowerCase(Locale.ROOT), option); + } + } + + List validSelections = new ArrayList<>(); + for (String selection : sanitizedSelections) { + String normalized = selection.toLowerCase(Locale.ROOT); + String resolved = allowedLookup.get(normalized); + if (resolved != null) { + validSelections.add(resolved); + } else { + log.debug( + "Ignoring unsupported option '{}' for choice field '{}'", + selection, + fieldName); + } + } + return validSelections; + } + + List parseMultiChoiceSelections(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + } + + List collectChoiceAllowedValues(PDChoice choiceField) { + if (choiceField == null) { + return Collections.emptyList(); + } + + LinkedHashSet allowed = new LinkedHashSet<>(); + + try { + List exports = choiceField.getOptionsExportValues(); + if (exports != null) { + exports.stream() + .filter(Objects::nonNull) + .forEach( + option -> { + String cleaned = option.trim(); + if (!cleaned.isEmpty()) { + allowed.add(option); + } + }); + } + } catch (Exception e) { + log.debug( + "Unable to read export values for choice field '{}': {}", + choiceField.getFullyQualifiedName(), + e.getMessage()); + } + + try { + List display = choiceField.getOptionsDisplayValues(); + if (display != null) { + display.stream() + .filter(Objects::nonNull) + .forEach( + option -> { + String cleaned = option.trim(); + if (!cleaned.isEmpty()) { + allowed.add(option); + } + }); + } + } catch (Exception e) { + log.debug( + "Unable to read display values for choice field '{}': {}", + choiceField.getFullyQualifiedName(), + e.getMessage()); + } + + if (allowed.isEmpty()) { + return Collections.emptyList(); + } + + return new ArrayList<>(allowed); + } + + boolean isChecked(String value) { + if (value == null) return false; + String normalized = value.trim().toLowerCase(); + return "true".equals(normalized) + || "1".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized) + || "checked".equals(normalized); + } + + private LinkedHashSet collectCheckBoxStates(PDCheckBox checkBox) { + LinkedHashSet states = new LinkedHashSet<>(); + try { + String onValue = checkBox.getOnValue(); + if (isSettableCheckBoxState(onValue)) { + states.add(onValue.trim()); + } + } catch (Exception e) { + log.debug( + "Failed to obtain explicit on-value for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + + try { + for (PDAnnotationWidget widget : checkBox.getWidgets()) { + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + continue; + } + PDAppearanceEntry normal = appearance.getNormalAppearance(); + if (normal == null) { + continue; + } + if (normal.isSubDictionary()) { + Map entries = normal.getSubDictionary(); + if (entries != null) { + for (COSName name : entries.keySet()) { + String state = name.getName(); + if (isSettableCheckBoxState(state)) { + states.add(state.trim()); + } + } + } + } else if (normal.isStream()) { + COSName appearanceState = widget.getAppearanceState(); + String state = appearanceState != null ? appearanceState.getName() : null; + if (isSettableCheckBoxState(state)) { + states.add(state.trim()); + } + } + } + } catch (Exception e) { + log.debug( + "Failed to obtain appearance states for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + + try { + List exports = checkBox.getExportValues(); + if (exports != null) { + for (String export : exports) { + if (isSettableCheckBoxState(export)) { + states.add(export.trim()); + } + } + } + } catch (Exception e) { + log.debug( + "Failed to obtain export values for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + return states; + } + + private String safeValue(PDTerminalField field) { + try { + return field.getValueAsString(); + } catch (Exception e) { + log.debug( + "Failed to read current value for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + return null; + } + } + + List resolveOptions(PDTerminalField field) { + try { + if (field instanceof PDChoice choice) { + List display = choice.getOptionsDisplayValues(); + if (display != null && !display.isEmpty()) { + return new ArrayList<>(display); + } + List exportValues = choice.getOptionsExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return new ArrayList<>(exportValues); + } + } else if (field instanceof PDRadioButton radio) { + List exports = radio.getExportValues(); + if (exports != null && !exports.isEmpty()) { + return new ArrayList<>(exports); + } + } else if (field instanceof PDCheckBox checkBox) { + List exports = checkBox.getExportValues(); + if (exports != null && !exports.isEmpty()) { + return new ArrayList<>(exports); + } + } + } catch (Exception e) { + log.debug( + "Failed to resolve options for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + return Collections.emptyList(); + } + + private boolean resolveMultiSelect(PDTerminalField field) { + if (field instanceof PDListBox listBox) { + try { + return listBox.isMultiSelect(); + } catch (Exception e) { + log.debug( + "Failed to resolve multi-select flag for list box '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return false; + } + + private boolean isSettableCheckBoxState(String state) { + if (state == null) return false; + String trimmed = state.trim(); + return !trimmed.isEmpty() && !"Off".equalsIgnoreCase(trimmed); + } + + private boolean shouldCheckBoxBeChecked(String value, LinkedHashSet candidateStates) { + if (value == null) { + return false; + } + if (isChecked(value)) { + return true; + } + String normalized = value.trim(); + if (normalized.isEmpty() || "off".equalsIgnoreCase(normalized)) { + return false; + } + for (String state : candidateStates) { + if (state.equalsIgnoreCase(normalized)) { + return true; + } + } + return false; + } + + private String deriveDisplayLabel( + PDField field, + String name, + String tooltip, + String type, + int typeIndex, + List options) { + String alternate = cleanLabel(field.getAlternateFieldName()); + if (alternate != null && !looksGeneric(alternate)) { + return alternate; + } + + String tooltipLabel = cleanLabel(tooltip); + if (tooltipLabel != null && !looksGeneric(tooltipLabel)) { + return tooltipLabel; + } + + // Only check options for choice-type fields (combobox, listbox, radio) + if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + String optionCandidate = cleanLabel(options.get(0)); + if (optionCandidate != null && !looksGeneric(optionCandidate)) { + return optionCandidate; + } + } + + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + return fallbackLabelForType(type, typeIndex); + } + + private String cleanLabel(String label) { + if (label == null) return null; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String cleaned = label.trim(); + + cleaned = patterns.getPattern("[.:]+$").matcher(cleaned).replaceAll("").trim(); + + return cleaned.isEmpty() ? null : cleaned; + } + + private boolean looksGeneric(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + + if (simplified.isEmpty()) return true; + + return patterns.getGenericFieldNamePattern().matcher(simplified).matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + + private String humanizeName(String name) { + if (name == null) return null; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + + String cleaned = patterns.getFormFieldBracketPattern().matcher(name).replaceAll(" "); + cleaned = cleaned.replace('.', ' '); + cleaned = patterns.getUnderscoreHyphenPattern().matcher(cleaned).replaceAll(" "); + cleaned = patterns.getCamelCaseBoundaryPattern().matcher(cleaned).replaceAll(" "); + cleaned = patterns.getWhitespacePattern().matcher(cleaned).replaceAll(" ").trim(); + + return cleaned.isEmpty() ? null : cleaned; + } + + public void modifyFormFields( + PDDocument document, List modifications) { + if (document == null || modifications == null || modifications.isEmpty()) return; + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + log.warn("Cannot modify fields because the document has no AcroForm"); + return; + } + + Set existingNames = collectExistingFieldNames(acroForm); + + for (ModifyFormFieldDefinition modification : modifications) { + if (modification == null || modification.targetName() == null) { + continue; + } + + String lookupName = modification.targetName().trim(); + if (lookupName.isEmpty()) { + continue; + } + + PDField originalField = locateField(acroForm, lookupName); + if (originalField == null) { + log.warn("No matching field '{}' found for modification", lookupName); + continue; + } + + List widgets = originalField.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + log.warn("Field '{}' has no widgets; skipping modification", lookupName); + continue; + } + + PDAnnotationWidget widget = widgets.get(0); + PDRectangle originalRectangle = cloneRectangle(widget.getRectangle()); + PDPage page = resolveWidgetPage(document, widget); + if (page == null || originalRectangle == null) { + log.warn( + "Unable to resolve widget page or rectangle for '{}'; skipping", + lookupName); + continue; + } + + String resolvedType = + Optional.ofNullable(modification.type()) + .map(FormUtils::normalizeFieldType) + .orElseGet(() -> detectFieldType(originalField)); + + if (!RegexPatternUtils.getInstance() + .getSupportedNewFieldTypes() + .contains(resolvedType)) { + log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + continue; + } + + String desiredName = + Optional.ofNullable(modification.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElseGet(originalField::getPartialName); + + if (desiredName != null) { + existingNames.remove(originalField.getFullyQualifiedName()); + existingNames.remove(originalField.getPartialName()); + desiredName = generateUniqueFieldName(desiredName, existingNames); + existingNames.add(desiredName); + } + + // Try to modify field in-place first for simple property changes + String currentType = detectFieldType(originalField); + boolean typeChanging = !currentType.equals(resolvedType); + + if (!typeChanging) { + try { + modifyFieldPropertiesInPlace(originalField, modification, desiredName); + log.debug("Successfully modified field '{}' in-place", lookupName); + continue; // Skip the remove-and-recreate process + } catch (Exception e) { + log.debug( + "In-place modification failed for '{}', falling back to recreation: {}", + lookupName, + e.getMessage()); + } + } + + // For type changes or when in-place modification fails, use remove-and-recreate + // But create the new field first to ensure success before removing the original + NewFormFieldDefinition replacementDefinition = + new NewFormFieldDefinition( + desiredName, + modification.label(), + resolvedType, + determineWidgetPageIndex(document, widget), + originalRectangle.getLowerLeftX(), + originalRectangle.getLowerLeftY(), + originalRectangle.getWidth(), + originalRectangle.getHeight(), + modification.required(), + modification.multiSelect(), + modification.options(), + modification.defaultValue(), + modification.tooltip()); + + List sanitizedOptions = sanitizeOptions(modification.options()); + + try { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + handler = FormFieldTypeSupport.TEXT; + } + + // Create new field first - if this fails, original field is preserved + createNewField( + handler, + acroForm, + page, + originalRectangle, + desiredName, + replacementDefinition, + sanitizedOptions); // Don't reuse widget for type changes + + removeFieldFromDocument(document, acroForm, originalField); + + log.debug( + "Successfully replaced field '{}' with type '{}'", + lookupName, + resolvedType); + } catch (Exception e) { + log.warn( + "Failed to modify form field '{}' to type '{}': {}", + lookupName, + resolvedType, + e.getMessage(), + e); + } + } + + ensureAppearances(acroForm); + } + + private void modifyFieldPropertiesInPlace( + PDField field, ModifyFormFieldDefinition modification, String newName) + throws IOException { + if (newName != null && !newName.equals(field.getPartialName())) { + field.setPartialName(newName); + } + + if (modification.label() != null) { + if (!modification.label().isBlank()) { + field.setAlternateFieldName(modification.label()); + } else { + field.setAlternateFieldName(null); + } + } + + if (modification.required() != null) { + field.setRequired(modification.required()); + } + + if (modification.defaultValue() != null) { + if (!modification.defaultValue().isBlank()) { + field.setValue(modification.defaultValue()); + } else { + field.setValue(null); + } + } + + if (field instanceof PDChoice choiceField + && (modification.options() != null || modification.multiSelect() != null)) { + + if (modification.options() != null) { + List sanitizedOptions = sanitizeOptions(modification.options()); + choiceField.setOptions(sanitizedOptions); + } + + if (modification.multiSelect() != null) { + choiceField.setMultiSelect(modification.multiSelect()); + } + } + + // Update tooltip on widgets + if (modification.tooltip() != null) { + List widgets = field.getWidgets(); + for (PDAnnotationWidget widget : widgets) { + if (!modification.tooltip().isBlank()) { + widget.getCOSObject().setString(COSName.TU, modification.tooltip()); + } else { + widget.getCOSObject().removeItem(COSName.TU); + } + } + } + } + + private String fallbackLabelForType(String type, int typeIndex) { + String suffix = " " + typeIndex; + return switch (type) { + case FIELD_TYPE_CHECKBOX -> "Checkbox" + suffix; + case FIELD_TYPE_RADIO -> "Option" + suffix; + case FIELD_TYPE_COMBOBOX -> "Dropdown" + suffix; + case FIELD_TYPE_LISTBOX -> "List" + suffix; + case FIELD_TYPE_TEXT -> "Text field" + suffix; + default -> "Field" + suffix; + }; + } + + private String resolveTooltip(PDTerminalField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return null; + } + for (PDAnnotationWidget widget : widgets) { + if (widget == null) { + continue; + } + try { + String alt = widget.getAnnotationName(); + if (alt != null && !alt.isBlank()) { + return alt; + } + String tooltip = widget.getCOSObject().getString(COSName.TU); + if (tooltip != null && !tooltip.isBlank()) { + return tooltip; + } + } catch (Exception e) { + log.debug( + "Failed to read tooltip for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return null; + } + + private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return -1; + } + Map widgetPageFallbacks = null; + for (PDAnnotationWidget widget : widgets) { + int idx = resolveWidgetPageIndex(document, widget); + if (idx >= 0) { + return idx; + } + try { + COSDictionary widgetDictionary = widget.getCOSObject(); + if (widgetDictionary != null + && widgetDictionary.getDictionaryObject(COSName.P) == null) { + if (widgetPageFallbacks == null) { + widgetPageFallbacks = buildWidgetPageFallbackMap(document); + } + Integer fallbackIndex = widgetPageFallbacks.get(widget); + if (fallbackIndex != null && fallbackIndex >= 0) { + return fallbackIndex; + } + } + } catch (Exception e) { + log.debug( + "Failed to inspect widget page reference for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return -1; + } + + private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + if (document == null || widget == null) { + return -1; + } + try { + COSDictionary widgetDictionary = widget.getCOSObject(); + if (widgetDictionary != null + && widgetDictionary.getDictionaryObject(COSName.P) == null) { + Map fallback = buildWidgetPageFallbackMap(document); + Integer index = fallback.get(widget); + if (index != null) { + return index; + } + } + } catch (Exception e) { + log.debug("Widget page lookup via fallback map failed: {}", e.getMessage()); + } + try { + PDPage page = widget.getPage(); + if (page != null) { + int idx = document.getPages().indexOf(page); + if (idx >= 0) { + return idx; + } + } + } catch (Exception e) { + log.debug("Widget page lookup failed: {}", e.getMessage()); + } + + int pageCount = document.getNumberOfPages(); + for (int i = 0; i < pageCount; i++) { + try { + PDPage candidate = document.getPage(i); + List annotations = candidate.getAnnotations(); + for (PDAnnotation annotation : annotations) { + if (annotation == widget) { + return i; + } + } + } catch (IOException e) { + log.debug("Failed to inspect annotations for page {}: {}", i, e.getMessage()); + } + } + return -1; + } + + public void deleteFormFields(PDDocument document, List fieldNames) { + if (document == null || fieldNames == null || fieldNames.isEmpty()) return; + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + log.warn("Cannot delete fields because the document has no AcroForm"); + return; + } + + for (String name : fieldNames) { + if (name == null || name.isBlank()) { + continue; + } + + PDField field = locateField(acroForm, name.trim()); + if (field == null) { + log.warn("No matching field '{}' found for deletion", name); + continue; + } + + removeFieldFromDocument(document, acroForm, field); + } + + ensureAppearances(acroForm); + } + + private void removeFieldFromDocument(PDDocument document, PDAcroForm acroForm, PDField field) { + if (field == null) return; + + try { + List widgets = field.getWidgets(); + if (widgets != null) { + for (PDAnnotationWidget widget : widgets) { + PDPage page = resolveWidgetPage(document, widget); + if (page != null) { + page.getAnnotations().remove(widget); + } + } + widgets.clear(); + } + + PDNonTerminalField parent = field.getParent(); + if (parent != null) { + List children = parent.getChildren(); + if (children != null) { + children.removeIf(existing -> existing == field); + } + + try { + COSArray kids = parent.getCOSObject().getCOSArray(COSName.KIDS); + if (kids != null) { + kids.removeObject(field.getCOSObject()); + } + } catch (Exception e) { + log.debug( + "Failed to remove field '{}' from parent kids array: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + if (acroForm != null) { + pruneFieldReferences(acroForm.getFields(), field); + + try { + COSArray fieldsArray = acroForm.getCOSObject().getCOSArray(COSName.FIELDS); + if (fieldsArray != null) { + fieldsArray.removeObject(field.getCOSObject()); + } + } catch (Exception e) { + log.debug( + "Failed to remove field '{}' from AcroForm COS array: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + try { + field.getCOSObject().clear(); + } catch (Exception e) { + log.debug( + "Failed to clear COS dictionary for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } catch (Exception e) { + log.warn( + "Failed to detach field '{}' from document: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + private void pruneFieldReferences(List fields, PDField target) { + if (fields == null || fields.isEmpty() || target == null) return; + + fields.removeIf(existing -> isSameFieldReference(existing, target)); + + for (PDField existing : List.copyOf(fields)) { + if (existing instanceof PDNonTerminalField nonTerminal) { + List children = nonTerminal.getChildren(); + if (children != null && !children.isEmpty()) { + pruneFieldReferences(children, target); + } + } + } + } + + private boolean isSameFieldReference(PDField a, PDField b) { + if (a == b) return true; + if (a == null || b == null) return false; + + String aName = a.getFullyQualifiedName(); + String bName = b.getFullyQualifiedName(); + if (aName != null && aName.equals(bName)) return true; + + String aPartial = a.getPartialName(); + String bPartial = b.getPartialName(); + return aPartial != null && aPartial.equals(bPartial); + } + + private void createNewField( + FormFieldTypeSupport handler, + PDAcroForm acroForm, + PDPage page, + PDRectangle rectangle, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + if (handler.doesNotsupportsDefinitionCreation()) { + throw new IllegalArgumentException( + "Field type '" + handler.typeName() + "' cannot be created via definition"); + } + + PDTerminalField field = handler.createField(acroForm); + registerNewField(field, acroForm, page, rectangle, name, definition, null); + List preparedOptions = options != null ? options : List.of(); + handler.applyNewFieldDefinition(field, definition, preparedOptions); + } + + private PDRectangle cloneRectangle(PDRectangle rectangle) { + if (rectangle == null) { + return null; + } + return new PDRectangle( + rectangle.getLowerLeftX(), + rectangle.getLowerLeftY(), + rectangle.getWidth(), + rectangle.getHeight()); + } + + private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) { + if (widget == null) { + return null; + } + PDPage page = widget.getPage(); + if (page != null) { + return page; + } + int pageIndex = determineWidgetPageIndex(document, widget); + if (pageIndex >= 0) { + try { + return document.getPage(pageIndex); + } catch (Exception e) { + log.debug("Failed to resolve widget page index {}: {}", pageIndex, e.getMessage()); + } + } + return null; + } + + private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + if (document == null || widget == null) { + return -1; + } + + PDPage directPage = widget.getPage(); + if (directPage != null) { + int index = 0; + for (PDPage page : document.getPages()) { + if (page == directPage) { + return index; + } + index++; + } + } + + int pageCount = document.getNumberOfPages(); + for (int i = 0; i < pageCount; i++) { + try { + PDPage page = document.getPage(i); + for (PDAnnotation annotation : page.getAnnotations()) { + if (annotation == widget) { + return i; + } + } + } catch (IOException e) { + log.debug("Failed to inspect annotations for page {}: {}", i, e.getMessage()); + } + } + return -1; + } + + private Map buildWidgetPageFallbackMap(PDDocument document) { + if (document == null) { + return Collections.emptyMap(); + } + + Map widgetToPage = new IdentityHashMap<>(); + int pageCount = document.getNumberOfPages(); + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + PDPage page; + try { + page = document.getPage(pageIndex); + } catch (Exception e) { + log.debug( + "Failed to access page {} while building widget map: {}", + pageIndex, + e.getMessage()); + continue; + } + + List annotations; + try { + annotations = page.getAnnotations(); + } catch (IOException e) { + log.debug( + "Failed to access annotations for page {}: {}", pageIndex, e.getMessage()); + continue; + } + + if (annotations == null || annotations.isEmpty()) { + continue; + } + + for (PDAnnotation annotation : annotations) { + if (!(annotation instanceof PDAnnotationWidget widget)) { + continue; + } + + COSDictionary widgetDictionary; + try { + widgetDictionary = widget.getCOSObject(); + } catch (Exception e) { + log.debug( + "Failed to access widget dictionary while building fallback map: {}", + e.getMessage()); + continue; + } + + if (widgetDictionary == null + || widgetDictionary.getDictionaryObject(COSName.P) != null) { + continue; + } + + widgetToPage.putIfAbsent(widget, pageIndex); + } + } + + return widgetToPage.isEmpty() ? Collections.emptyMap() : widgetToPage; + } + + private Set collectExistingFieldNames(PDAcroForm acroForm) { + if (acroForm == null) { + return Collections.emptySet(); + } + Set existing = new HashSet<>(); + for (PDField field : acroForm.getFieldTree()) { + if (field instanceof PDTerminalField) { + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); + } + } + } + return existing; + } + + private PDField locateField(PDAcroForm acroForm, String name) { + if (acroForm == null || name == null) { + return null; + } + PDField direct = acroForm.getField(name); + if (direct != null) { + return direct; + } + for (PDField field : acroForm.getFieldTree()) { + if (field == null) { + continue; + } + String fq = field.getFullyQualifiedName(); + if (name.equals(fq)) { + return field; + } + String partial = field.getPartialName(); + if (name.equals(partial)) { + return field; + } + } + return null; + } + + private String normalizeFieldType(String type) { + if (type == null) { + return FIELD_TYPE_TEXT; + } + String normalized = type.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + return FIELD_TYPE_TEXT; + } + return normalized; + } + + private String generateUniqueFieldName(String baseName, Set existingNames) { + String sanitized = + Optional.ofNullable(baseName) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + + StringBuilder candidateBuilder = new StringBuilder(sanitized); + String candidate = candidateBuilder.toString(); + int counter = 1; + + while (existingNames.contains(candidate)) { + candidateBuilder.setLength(0); + candidateBuilder.append(sanitized).append("_").append(counter); + candidate = candidateBuilder.toString(); + counter++; + } + + return candidate; + } + + private List sanitizeOptions(List options) { + if (options == null || options.isEmpty()) { + return List.of(); + } + return options.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + private void registerNewField( + T field, + PDAcroForm acroForm, + PDPage page, + PDRectangle rectangle, + String name, + NewFormFieldDefinition definition, + PDAnnotationWidget existingWidget) + throws IOException { + + field.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + field.setAlternateFieldName(definition.label()); + } catch (Exception e) { + log.debug("Unable to set alternate field name for '{}': {}", name, e.getMessage()); + } + } + field.setRequired(Boolean.TRUE.equals(definition.required())); + + PDAnnotationWidget widget = + existingWidget != null ? existingWidget : new PDAnnotationWidget(); + + // Ensure rectangle is valid and set before any appearance-related operations + // please note removal of this might cause **subtle** issues + PDRectangle validRectangle = rectangle; + if (validRectangle == null + || validRectangle.getWidth() <= 0 + || validRectangle.getHeight() <= 0) { + log.warn("Invalid rectangle for field '{}', using default dimensions", name); + validRectangle = new PDRectangle(100, 100, 100, 20); + } + widget.setRectangle(validRectangle); + widget.setPage(page); + + if (existingWidget == null) { + widget.setPrinted(true); + } + + if (definition.tooltip() != null && !definition.tooltip().isBlank()) { + widget.getCOSObject().setString(COSName.TU, definition.tooltip()); + } else { + try { + widget.getCOSObject().removeItem(COSName.TU); + } catch (Exception e) { + log.debug("Unable to clear tooltip for '{}': {}", name, e.getMessage()); + } + } + + field.getWidgets().add(widget); + widget.setParent(field); + + List annotations = page.getAnnotations(); + if (annotations == null) { + page.getAnnotations().add(widget); + } else if (!annotations.contains(widget)) { + annotations.add(widget); + } + acroForm.getFields().add(field); + } + + // Delegation methods to FormCopyUtils for form field transformation + public boolean hasAnyRotatedPage(PDDocument document) { + return FormCopyUtils.hasAnyRotatedPage(document); + } + + public void copyAndTransformFormFields( + PDDocument sourceDocument, + PDDocument newDocument, + int totalPages, + int pagesPerSheet, + int cols, + int rows, + float cellWidth, + float cellHeight) + throws IOException { + FormCopyUtils.copyAndTransformFormFields( + sourceDocument, + newDocument, + totalPages, + pagesPerSheet, + cols, + rows, + cellWidth, + cellHeight); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FormFieldExtraction(List fields, Map template) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FormFieldInfo( + String name, + String label, + String type, + String value, + List options, + boolean required, + int pageIndex, + boolean multiSelect, + String tooltip, + int pageOrder) {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java new file mode 100644 index 0000000000..6416e82bf3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior") +class FormUtilsTest { + + private static SetupDocument createBasicDocument(PDDocument document) throws IOException { + PDPage page = new PDPage(); + document.addPage(page); + + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + acroForm.setNeedAppearances(true); + document.getDocumentCatalog().setAcroForm(acroForm); + + return new SetupDocument(page, acroForm); + } + + private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle) + throws IOException { + attachWidget(setup, field, rectangle); + } + + private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle) + throws IOException { + field.setExportValues(List.of("Yes")); + attachWidget(setup, field, rectangle); + } + + private static void attachWidget( + SetupDocument setup, + org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field, + PDRectangle rectangle) + throws IOException { + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rectangle); + widget.setPage(setup.page); + List widgets = field.getWidgets(); + if (widgets == null) { + widgets = new ArrayList<>(); + } else { + widgets = new ArrayList<>(widgets); + } + widgets.add(widget); + field.setWidgets(widgets); + setup.acroForm.getFields().add(field); + setup.page.getAnnotations().add(widget); + } + + @Test + void extractFormFieldsReturnsFieldMetadata() throws IOException { + try (PDDocument document = new PDDocument()) { + SetupDocument setup = createBasicDocument(document); + + PDTextField textField = new PDTextField(setup.acroForm); + textField.setPartialName("firstName"); + attachField(setup, textField, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(document); + assertEquals(1, fields.size()); + FormUtils.FormFieldInfo info = fields.get(0); + assertEquals("firstName", info.name()); + assertEquals("text", info.type()); + assertEquals(0, info.pageIndex()); + assertEquals("", info.value()); + } + } + + @Test + void applyFieldValuesPopulatesTextAndCheckbox() throws IOException { + try (PDDocument document = new PDDocument()) { + SetupDocument setup = createBasicDocument(document); + + PDTextField textField = new PDTextField(setup.acroForm); + textField.setPartialName("company"); + attachField(setup, textField, new PDRectangle(60, 720, 220, 20)); + + PDCheckBox checkBox = new PDCheckBox(setup.acroForm); + checkBox.setPartialName("subscribed"); + attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16)); + + FormUtils.applyFieldValues( + document, Map.of("company", "Stirling", "subscribed", true), false); + + assertEquals("Stirling", textField.getValueAsString()); + assertTrue(checkBox.isChecked()); + + FormUtils.applyFieldValues(document, Map.of("subscribed", false), false); + assertFalse(checkBox.isChecked()); + assertEquals("Off", checkBox.getValue()); + } + } + + private record SetupDocument(PDPage page, PDAcroForm acroForm) {} +} From 044bf3c2aa56174374c7fc1f871a4f0ec7710f5d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 11 Nov 2025 11:54:43 +0000 Subject: [PATCH 04/16] Improve loading speed of desktop app (#4865) # Description of Changes Improve loading speed of desktop app by loading a default config until the backend has spawned. --- frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 4 +- frontend/src/core/components/AppProviders.tsx | 13 +- .../tools/shared/OperationButton.tsx | 22 ++- .../src/core/contexts/AppConfigContext.tsx | 51 +++-- .../hooks/tools/shared/useToolOperation.ts | 7 + frontend/src/core/hooks/useBackendHealth.ts | 12 ++ .../core/services/backendReadinessGuard.ts | 7 + frontend/src/core/types/backendHealth.ts | 10 + .../src/desktop/components/AppProviders.tsx | 8 + .../desktop/components/DesktopConfigSync.tsx | 23 +++ .../src/desktop/config/defaultAppConfig.ts | 10 + .../src/desktop/constants/backendErrors.ts | 20 ++ .../src/desktop/hooks/useBackendHealth.ts | 91 ++------- .../src/desktop/hooks/useEndpointConfig.ts | 176 +++++++++++++++--- .../src/desktop/services/apiClientSetup.ts | 44 +++++ .../desktop/services/backendHealthMonitor.ts | 125 +++++++++++++ .../desktop/services/backendReadinessGuard.ts | 35 ++++ .../desktop/services/tauriBackendService.ts | 81 ++++++-- frontend/src/global.d.ts | 12 ++ .../proprietary/components/AppProviders.tsx | 7 +- 21 files changed, 622 insertions(+), 137 deletions(-) create mode 100644 frontend/src/core/hooks/useBackendHealth.ts create mode 100644 frontend/src/core/services/backendReadinessGuard.ts create mode 100644 frontend/src/core/types/backendHealth.ts create mode 100644 frontend/src/desktop/components/DesktopConfigSync.tsx create mode 100644 frontend/src/desktop/config/defaultAppConfig.ts create mode 100644 frontend/src/desktop/constants/backendErrors.ts create mode 100644 frontend/src/desktop/services/apiClientSetup.ts create mode 100644 frontend/src/desktop/services/backendHealthMonitor.ts create mode 100644 frontend/src/desktop/services/backendReadinessGuard.ts diff --git a/frontend/package.json b/frontend/package.json index 7b9480ece9..b0989a0dff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,6 +66,7 @@ "preview": "vite preview", "tauri-dev": "tauri dev --no-watch", "tauri-build": "tauri build", + "tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build", "typecheck": "npm run typecheck:proprietary", "typecheck:core": "tsc --noEmit --project tsconfig.core.json", "typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 626f16f445..81e2f0c64c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5137,6 +5137,8 @@ "backendHealth": { "checking": "Checking backend status...", "online": "Backend Online", - "offline": "Backend Offline" + "offline": "Backend Offline", + "starting": "Backend starting up...", + "wait": "Please wait for the backend to finish launching and try again." } } diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 2108b3550a..3bf96f29f3 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -8,7 +8,7 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; -import { AppConfigProvider, AppConfigRetryOptions } from "@app/contexts/AppConfigContext"; +import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext"; import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; @@ -31,22 +31,29 @@ function AppInitializer() { return null; } +// Avoid requirement to have props which are required in app providers anyway +type AppConfigProviderOverrides = Omit; + export interface AppProvidersProps { children: ReactNode; appConfigRetryOptions?: AppConfigRetryOptions; + appConfigProviderProps?: Partial; } /** * Core application providers * Contains all providers needed for the core */ -export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) { +export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { return ( - + diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index 48e4d5a6a9..656c4d195f 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -1,5 +1,7 @@ import { Button } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@app/components/shared/Tooltip'; +import { useBackendHealth } from '@app/hooks/useBackendHealth'; export interface OperationButtonProps { onClick?: () => void; @@ -31,8 +33,14 @@ const OperationButton = ({ 'data-tour': dataTour }: OperationButtonProps) => { const { t } = useTranslation(); + const { isHealthy, message: backendMessage } = useBackendHealth(); + const blockedByBackend = !isHealthy; + const combinedDisabled = disabled || blockedByBackend; + const tooltipLabel = blockedByBackend + ? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...')) + : null; - return ( + const button = ( + + + {renderSelectedFile('base')} +
+ {/* Edited PDF section header */} + {t('compare.edited.label', 'Edited PDF')} + {renderSelectedFile('comparison')} + + {hasBothSelected && ( + + + + )} + setSwapConfirmOpen(false)} + title={t('compare.swap.confirmTitle', 'Re-run comparison?')} + centered + size="sm" + > + + {t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')} + + + + + + + setClearConfirmOpen(false)} + title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')} + centered + size="sm" + > + + {t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')} + + + + + + + + ), + }, + ], + executeButton: { + text: t('compare.cta', 'Compare'), + loadingText: t('compare.loading', 'Comparing...'), + onClick: handleExecuteCompare, + disabled: !canExecute, + testId: 'compare-execute', + }, + review: { + isVisible: false, + operation: base.operation, + title: t('compare.review.title', 'Comparison Result'), + onUndo: base.operation.undoOperation, + }, + }); +}; + +const CompareTool = Compare as ToolComponent; +CompareTool.tool = () => useCompareOperation; +CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters }); + +export default CompareTool; + + + diff --git a/frontend/src/core/tools/ValidateSignature.tsx b/frontend/src/core/tools/ValidateSignature.tsx index 5ad21c83de..30fab68d99 100644 --- a/frontend/src/core/tools/ValidateSignature.tsx +++ b/frontend/src/core/tools/ValidateSignature.tsx @@ -39,6 +39,8 @@ const ValidateSignature = (props: BaseToolProps) => { const hasResults = operation.results.length > 0; const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage; + + useEffect(() => { registerCustomWorkbenchView({ id: REPORT_VIEW_ID, diff --git a/frontend/src/core/types/compare.ts b/frontend/src/core/types/compare.ts new file mode 100644 index 0000000000..65b6ea12c9 --- /dev/null +++ b/frontend/src/core/types/compare.ts @@ -0,0 +1,314 @@ +import type { FileId } from '@app/types/file'; +import type { StirlingFile } from '@app/types/fileContext'; + +export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added'; + +export interface CompareDiffToken { + type: CompareDiffTokenType; + text: string; +} + +export const REMOVAL_HIGHLIGHT = '#FF3B30'; +export const ADDITION_HIGHLIGHT = '#34C759'; +export const PARAGRAPH_SENTINEL = '\uE000¶'; + +export interface TokenBoundingBox { + left: number; + top: number; + width: number; + height: number; +} + +export interface CompareTokenMetadata { + page: number; + paragraph: number; + bbox: TokenBoundingBox | null; +} + +export interface ComparePageSize { + width: number; + height: number; +} + +export interface CompareDocumentInfo { + fileId: string; + fileName: string; + highlightColor: string; + wordCount: number; + pageSizes: ComparePageSize[]; +} + +export interface CompareParagraph { + page: number; + paragraph: number; + text: string; +} + +export interface CompareFilteredTokenInfo { + token: string; + page: number | null; + paragraph: number | null; + bbox: TokenBoundingBox | null; + hasHighlight: boolean; + metaIndex: number; +} + +export interface CompareChangeSide { + text: string; + page: number | null; + paragraph: number | null; +} + +export interface CompareChange { + id: string; + base: CompareChangeSide | null; + comparison: CompareChangeSide | null; +} + +export interface CompareResultData { + base: CompareDocumentInfo; + comparison: CompareDocumentInfo; + totals: { + added: number; + removed: number; + unchanged: number; + durationMs: number; + processedAt: number; + }; + tokens: CompareDiffToken[]; + tokenMetadata: { + base: CompareTokenMetadata[]; + comparison: CompareTokenMetadata[]; + }; + filteredTokenData: { + base: CompareFilteredTokenInfo[]; + comparison: CompareFilteredTokenInfo[]; + }; + sourceTokens: { + base: string[]; + comparison: string[]; + }; + changes: CompareChange[]; + warnings: string[]; + baseParagraphs: CompareParagraph[]; + comparisonParagraphs: CompareParagraph[]; +} + +export interface CompareWorkerWarnings { + complexMessage?: string; + tooLargeMessage?: string; + emptyTextMessage?: string; + tooDissimilarMessage?: string; +} + +export interface CompareWorkerRequest { + type: 'compare'; + payload: { + baseTokens: string[]; + comparisonTokens: string[]; + warnings: CompareWorkerWarnings; + settings?: { + batchSize?: number; + complexThreshold?: number; + maxWordThreshold?: number; + // Early-stop and runtime controls (optional) + earlyStopEnabled?: boolean; + minJaccardUnigram?: number; + minJaccardBigram?: number; + minTokensForEarlyStop?: number; + sampleLimit?: number; + runtimeMaxProcessedTokens?: number; + runtimeMinUnchangedRatio?: number; + }; + }; +} + +export type CompareWorkerResponse = + | { + type: 'chunk'; + tokens: CompareDiffToken[]; + } + | { + type: 'success'; + stats: { + baseWordCount: number; + comparisonWordCount: number; + durationMs: number; + }; + } + | { + type: 'warning'; + message: string; + } + | { + type: 'error'; + message: string; + code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR'; + }; + +export interface CompareDocumentPaneProps { + pane: 'base' | 'comparison'; + layout: 'side-by-side' | 'stacked'; + scrollRef: React.RefObject; + peerScrollRef: React.RefObject; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchEnd: (event: React.TouchEvent) => void; + isPanMode: boolean; + zoom: number; + title: string; + dropdownPlaceholder?: React.ReactNode; + changes: Array<{ value: string; label: string; pageNumber?: number }>; + onNavigateChange: (id: string, pageNumber?: number) => void; + isLoading: boolean; + processingMessage: string; + pages: PagePreview[]; + pairedPages: PagePreview[]; + getRowHeightPx: (pageNumber: number) => number; + wordHighlightMap: Map; + metaIndexToGroupId: Map; + documentLabel: string; + pageLabel: string; + altLabel: string; + // Page input/navigation props (optional to keep call sites flexible) + pageInputValue?: string; + onPageInputChange?: (next: string) => void; + maxSharedPages?: number; // min(baseTotal, compTotal) + renderedPageNumbers?: Set; + onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void; +} + +// Import types that are referenced in CompareDocumentPaneProps +export interface PagePreview { + pageNumber: number; + width: number; + height: number; + rotation: number; + url: string | null; +} + +export interface WordHighlightEntry { + rect: TokenBoundingBox; + metaIndex: number; +} + +export interface NavigationDropdownProps { + changes: Array<{ value: string; label: string; pageNumber?: number }>; + placeholder: React.ReactNode; + className?: string; + onNavigate: (value: string, pageNumber?: number) => void; + // Optional: pages that currently have previews rendered (1-based page numbers) + renderedPageNumbers?: Set; +} + +// Pan/Zoom and Compare Workbench shared types (moved out of hooks for reuse) +import type React from 'react'; + +export type ComparePane = 'base' | 'comparison'; + +export interface PanState { + x: number; + y: number; +} + +export interface ScrollLinkDelta { + vertical: number; + horizontal: number; +} + +export interface ScrollLinkAnchors { + deltaPixelsBaseToComp: number; + deltaPixelsCompToBase: number; +} + +export interface PanDragState { + active: boolean; + source: ComparePane | null; + startX: number; + startY: number; + startPanX: number; + startPanY: number; + targetStartPanX: number; + targetStartPanY: number; +} + +export interface PinchState { + active: boolean; + pane: ComparePane | null; + startDistance: number; + startZoom: number; +} + +export interface UseComparePanZoomOptions { + prefersStacked: boolean; + basePages: PagePreview[]; + comparisonPages: PagePreview[]; +} + +export interface UseComparePanZoomReturn { + layout: 'side-by-side' | 'stacked'; + setLayout: (layout: 'side-by-side' | 'stacked') => void; + toggleLayout: () => void; + baseScrollRef: React.RefObject; + comparisonScrollRef: React.RefObject; + isScrollLinked: boolean; + setIsScrollLinked: (value: boolean) => void; + captureScrollLinkDelta: () => void; + clearScrollLinkDelta: () => void; + isPanMode: boolean; + setIsPanMode: (value: boolean) => void; + baseZoom: number; + setBaseZoom: (value: number) => void; + comparisonZoom: number; + setComparisonZoom: (value: number) => void; + basePan: PanState; + comparisonPan: PanState; + setPanToTopLeft: (pane: ComparePane) => void; + centerPanForZoom: (pane: ComparePane, zoom: number) => void; + clampPanForZoom: (pane: ComparePane, zoom: number) => void; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + beginPan: (pane: ComparePane, event: React.MouseEvent) => void; + continuePan: (event: React.MouseEvent) => void; + endPan: () => void; + handleWheelZoom: (pane: ComparePane, event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent) => void; + onTouchStart: (pane: ComparePane, event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchEnd: () => void; + zoomLimits: { min: number; max: number; step: number }; +} + +export interface PagePreview { + pageNumber: number; + width: number; + height: number; + rotation: number; + url: string | null; +} + +export interface WordHighlightEntry { + rect: TokenBoundingBox; + metaIndex: number; +} + +// Removed legacy upload section types; upload flow now uses the standard active files workbench + +export interface CompareWorkbenchData { + result: CompareResultData | null; + baseFileId: FileId | null; + comparisonFileId: FileId | null; + onSelectBase?: (fileId: FileId | null) => void; + onSelectComparison?: (fileId: FileId | null) => void; + isLoading?: boolean; + baseLocalFile?: StirlingFile | null; + comparisonLocalFile?: StirlingFile | null; +} + +export interface CompareChangeOption { + value: string; + label: string; + pageNumber: number; +} \ No newline at end of file diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 2baf7586b6..25c1870205 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -86,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr } } - // Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations) + // Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations) if (!exportActions) { console.error('No export actions available'); return null; diff --git a/frontend/src/core/utils/textDiff.ts b/frontend/src/core/utils/textDiff.ts new file mode 100644 index 0000000000..fcf36a7d01 --- /dev/null +++ b/frontend/src/core/utils/textDiff.ts @@ -0,0 +1,50 @@ +// Shared text diff and normalization utilities for compare tool + +export const shouldConcatWithoutSpace = (word: string) => { + return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s"; +}; + +export const appendWord = (existing: string, word: string) => { + if (!existing) return word; + if (shouldConcatWithoutSpace(word)) return `${existing}${word}`; + return `${existing} ${word}`; +}; +export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean); + +type TokenType = 'unchanged' | 'removed' | 'added'; +export interface LocalToken { type: TokenType; text: string } + +const buildLcsMatrix = (a: string[], b: string[]) => { + const rows = a.length + 1; + const cols = b.length + 1; + const m: number[][] = new Array(rows); + for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0); + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + m[i][j] = a[i - 1] === b[j - 1] ? m[i - 1][j - 1] + 1 : Math.max(m[i][j - 1], m[i - 1][j]); + } + } + return m; +}; + +export const diffWords = (a: string[], b: string[]): LocalToken[] => { + const matrix = buildLcsMatrix(a, b); + const tokens: LocalToken[] = []; + let i = a.length; + let j = b.length; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + tokens.unshift({ type: 'unchanged', text: a[i - 1] }); + i -= 1; j -= 1; + } else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) { + tokens.unshift({ type: 'added', text: b[j - 1] }); + j -= 1; + } else if (i > 0) { + tokens.unshift({ type: 'removed', text: a[i - 1] }); + i -= 1; + } + } + return tokens; +}; + + diff --git a/frontend/src/core/workers/compareWorker.ts b/frontend/src/core/workers/compareWorker.ts new file mode 100644 index 0000000000..0e3a323e1c --- /dev/null +++ b/frontend/src/core/workers/compareWorker.ts @@ -0,0 +1,452 @@ +/// + +import type { + CompareDiffToken, + CompareWorkerRequest, + CompareWorkerResponse, +} from '@app/types/compare'; + +declare const self: DedicatedWorkerGlobalScope; + +const DEFAULT_SETTINGS = { + batchSize: 5000, + complexThreshold: 25000, + maxWordThreshold: 60000, + // Early stop configuration + earlyStopEnabled: true, + // Jaccard thresholds for quick prefilter (unigram/bigram) + minJaccardUnigram: 0.005, + minJaccardBigram: 0.003, + // Only consider early stop when docs are reasonably large + minTokensForEarlyStop: 20000, + // Sampling cap for similarity estimation + sampleLimit: 50000, + // Runtime stop-loss during chunked diff + runtimeMaxProcessedTokens: 150000, + runtimeMinUnchangedRatio: 0.001, +}; + +const buildMatrix = (words1: string[], words2: string[]) => { + const rows = words1.length + 1; + const cols = words2.length + 1; + const matrix: number[][] = new Array(rows); + + for (let i = 0; i < rows; i += 1) { + matrix[i] = new Array(cols).fill(0); + } + + for (let i = 1; i <= words1.length; i += 1) { + for (let j = 1; j <= words2.length; j += 1) { + matrix[i][j] = + words1[i - 1] === words2[j - 1] + ? matrix[i - 1][j - 1] + 1 + : Math.max(matrix[i][j - 1], matrix[i - 1][j]); + } + } + + return matrix; +}; + +const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => { + const tokens: CompareDiffToken[] = []; + let i = words1.length; + let j = words2.length; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) { + tokens.unshift({ type: 'unchanged', text: words1[i - 1] }); + i -= 1; + j -= 1; + } else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) { + tokens.unshift({ type: 'added', text: words2[j - 1] }); + j -= 1; + } else if (i > 0) { + tokens.unshift({ type: 'removed', text: words1[i - 1] }); + i -= 1; + } else { + j -= 1; + } + } + + return tokens; +}; + +const diff = (words1: string[], words2: string[]): CompareDiffToken[] => { + if (words1.length === 0 && words2.length === 0) { + return []; + } + + const matrix = buildMatrix(words1, words2); + return backtrack(matrix, words1, words2); +}; + +const countBaseTokens = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type !== 'added' ? 1 : 0), 0); + +const countComparisonTokens = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type !== 'removed' ? 1 : 0), 0); + +const findLastUnchangedIndex = (segment: CompareDiffToken[]) => { + for (let i = segment.length - 1; i >= 0; i -= 1) { + if (segment[i].type === 'unchanged') { + return i; + } + } + return -1; +}; + +const chunkedDiff = ( + words1: string[], + words2: string[], + chunkSize: number, + emit: (tokens: CompareDiffToken[]) => void, + runtimeStop?: { maxProcessedTokens: number; minUnchangedRatio: number } +) => { + if (words1.length === 0 && words2.length === 0) { + return; + } + + const baseChunkSize = Math.max(1, chunkSize); + let dynamicChunkSize = baseChunkSize; + const baseMaxWindow = Math.max(baseChunkSize * 6, baseChunkSize + 512); + let dynamicMaxWindow = baseMaxWindow; + let dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1)); + let dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5)); + let stallIterations = 0; + + const increaseChunkSizes = () => { + const maxChunkSize = baseChunkSize * 8; + if (dynamicChunkSize >= maxChunkSize) { + return; + } + const nextChunk = Math.min( + maxChunkSize, + Math.max(dynamicChunkSize + dynamicStep, Math.floor(dynamicChunkSize * 1.5)) + ); + if (nextChunk === dynamicChunkSize) { + return; + } + dynamicChunkSize = nextChunk; + dynamicMaxWindow = Math.max(dynamicMaxWindow, Math.max(dynamicChunkSize * 6, dynamicChunkSize + 512)); + dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1)); + dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5)); + }; + + let index1 = 0; + let index2 = 0; + let buffer1: string[] = []; + let buffer2: string[] = []; + let totalProcessedBase = 0; + let totalProcessedComp = 0; + let totalUnchanged = 0; + + const countUnchanged = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type === 'unchanged' ? 1 : 0), 0); + + const flushRemainder = () => { + if (buffer1.length === 0 && buffer2.length === 0) { + return; + } + const finalTokens = diff(buffer1, buffer2); + if (finalTokens.length > 0) { + emit(finalTokens); + } + buffer1 = []; + buffer2 = []; + index1 = words1.length; + index2 = words2.length; + }; + + while ( + index1 < words1.length || + index2 < words2.length || + buffer1.length > 0 || + buffer2.length > 0 + ) { + const remaining1 = Math.max(0, words1.length - index1); + const remaining2 = Math.max(0, words2.length - index2); + + let windowSize = Math.max(dynamicChunkSize, buffer1.length, buffer2.length); + let window1: string[] = []; + let window2: string[] = []; + let chunkTokens: CompareDiffToken[] = []; + let reachedEnd = false; + + while (true) { + const take1 = Math.min(Math.max(0, windowSize - buffer1.length), remaining1); + const take2 = Math.min(Math.max(0, windowSize - buffer2.length), remaining2); + + const slice1 = take1 > 0 ? words1.slice(index1, index1 + take1) : []; + const slice2 = take2 > 0 ? words2.slice(index2, index2 + take2) : []; + + window1 = buffer1.length > 0 ? [...buffer1, ...slice1] : slice1; + window2 = buffer2.length > 0 ? [...buffer2, ...slice2] : slice2; + + if (window1.length === 0 && window2.length === 0) { + flushRemainder(); + return; + } + + chunkTokens = diff(window1, window2); + const lastStableIndex = findLastUnchangedIndex(chunkTokens); + + reachedEnd = + index1 + take1 >= words1.length && + index2 + take2 >= words2.length; + + const windowTooLarge = + window1.length >= dynamicMaxWindow || + window2.length >= dynamicMaxWindow; + + if (lastStableIndex >= 0 || reachedEnd || windowTooLarge) { + break; + } + + const canGrow1 = take1 < remaining1; + const canGrow2 = take2 < remaining2; + + if (!canGrow1 && !canGrow2) { + break; + } + + windowSize = Math.min( + dynamicMaxWindow, + windowSize + dynamicStep + ); + } + + if (chunkTokens.length === 0) { + if (reachedEnd) { + flushRemainder(); + return; + } + windowSize = Math.min(windowSize + dynamicStep, dynamicMaxWindow); + stallIterations += 1; + if (stallIterations >= 3) { + increaseChunkSizes(); + stallIterations = 0; + } + continue; + } + + let commitIndex = reachedEnd ? chunkTokens.length - 1 : findLastUnchangedIndex(chunkTokens); + if (commitIndex < 0) { + commitIndex = reachedEnd + ? chunkTokens.length - 1 + : Math.min(chunkTokens.length - 1, dynamicMinCommit - 1); + } + + const commitTokens = commitIndex >= 0 ? chunkTokens.slice(0, commitIndex + 1) : []; + const baseConsumed = countBaseTokens(commitTokens); + const comparisonConsumed = countComparisonTokens(commitTokens); + + if (commitTokens.length > 0) { + emit(commitTokens); + } + + const consumedFromNew1 = Math.max(0, baseConsumed - buffer1.length); + const consumedFromNew2 = Math.max(0, comparisonConsumed - buffer2.length); + + index1 += consumedFromNew1; + index2 += consumedFromNew2; + + buffer1 = window1.slice(baseConsumed); + buffer2 = window2.slice(comparisonConsumed); + // Update runtime counters and early stop if necessary + totalProcessedBase += baseConsumed; + totalProcessedComp += comparisonConsumed; + totalUnchanged += countUnchanged(commitTokens); + + if (runtimeStop) { + const processedTotal = totalProcessedBase + totalProcessedComp; + if (processedTotal >= runtimeStop.maxProcessedTokens) { + const unchangedRatio = totalUnchanged / Math.max(1, processedTotal); + if (unchangedRatio < runtimeStop.minUnchangedRatio) { + // Signal early termination for extreme dissimilarity + const err = new Error('EARLY_STOP_TOO_DISSIMILAR'); + (err as Error & { __earlyStop?: boolean }).__earlyStop = true; + throw err; + } + } + } + + if (reachedEnd) { + flushRemainder(); + break; + } + + if (commitTokens.length < dynamicMinCommit) { + stallIterations += 1; + } else { + stallIterations = 0; + } + + if (commitTokens.length === 0 && buffer1.length + buffer2.length > 0) { + if (buffer1.length > 0 && index1 < words1.length) { + buffer1 = buffer1.slice(1); + index1 += 1; + } else if (buffer2.length > 0 && index2 < words2.length) { + buffer2 = buffer2.slice(1); + index2 += 1; + } + } + + if (stallIterations >= 3) { + increaseChunkSizes(); + stallIterations = 0; + } + } + + flushRemainder(); +}; + +// Fast similarity estimation using sampled unigrams and bigrams with Jaccard +const buildSampledSet = (tokens: string[], sampleLimit: number, ngram: 1 | 2): Set => { + const result = new Set(); + if (tokens.length === 0) return result; + const stride = Math.max(1, Math.ceil(tokens.length / sampleLimit)); + if (ngram === 1) { + for (let i = 0; i < tokens.length; i += stride) { + const t = tokens[i]; + if (t) result.add(t); + } + return result; + } + // ngram === 2 + for (let i = 0; i + 1 < tokens.length; i += stride) { + const a = tokens[i]; + const b = tokens[i + 1]; + if (a && b) result.add(`${a}|${b}`); + } + return result; +}; + +const jaccard = (a: Set, b: Set): number => { + if (a.size === 0 && b.size === 0) return 1; + if (a.size === 0 || b.size === 0) return 0; + let intersection = 0; + const smaller = a.size <= b.size ? a : b; + const larger = a.size <= b.size ? b : a; + for (const v of smaller) { + if (larger.has(v)) intersection += 1; + } + const union = a.size + b.size - intersection; + return union > 0 ? intersection / union : 0; +}; + +self.onmessage = (event: MessageEvent) => { + const { data } = event; + if (!data || data.type !== 'compare') { + return; + } + + const { baseTokens, comparisonTokens, warnings, settings } = data.payload; + const { + batchSize = DEFAULT_SETTINGS.batchSize, + complexThreshold = DEFAULT_SETTINGS.complexThreshold, + maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold, + earlyStopEnabled = DEFAULT_SETTINGS.earlyStopEnabled, + minJaccardUnigram = DEFAULT_SETTINGS.minJaccardUnigram, + minJaccardBigram = DEFAULT_SETTINGS.minJaccardBigram, + minTokensForEarlyStop = DEFAULT_SETTINGS.minTokensForEarlyStop, + sampleLimit = DEFAULT_SETTINGS.sampleLimit, + runtimeMaxProcessedTokens = DEFAULT_SETTINGS.runtimeMaxProcessedTokens, + runtimeMinUnchangedRatio = DEFAULT_SETTINGS.runtimeMinUnchangedRatio, + } = settings ?? {}; + + if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) { + const response: CompareWorkerResponse = { + type: 'error', + message: warnings.emptyTextMessage ?? 'One or both texts are empty.', + code: 'EMPTY_TEXT', + }; + self.postMessage(response); + return; + } + + if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) { + // For compare tool, do not fail hard; warn and continue with chunked diff + const response: CompareWorkerResponse = { + type: 'warning', + message: warnings.tooLargeMessage ?? 'Documents are too large to compare.', + }; + self.postMessage(response); + } + + const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold; + + if (isComplex && warnings.complexMessage) { + const warningResponse: CompareWorkerResponse = { + type: 'warning', + message: warnings.complexMessage, + }; + self.postMessage(warningResponse); + } + + // Quick prefilter to avoid heavy diff on extremely dissimilar large docs + if (earlyStopEnabled && Math.min(baseTokens.length, comparisonTokens.length) >= minTokensForEarlyStop) { + const set1u = buildSampledSet(baseTokens, sampleLimit, 1); + const set2u = buildSampledSet(comparisonTokens, sampleLimit, 1); + const jUni = jaccard(set1u, set2u); + const set1b = buildSampledSet(baseTokens, sampleLimit, 2); + const set2b = buildSampledSet(comparisonTokens, sampleLimit, 2); + const jBi = jaccard(set1b, set2b); + if (jUni < minJaccardUnigram && jBi < minJaccardBigram) { + const response: CompareWorkerResponse = { + type: 'error', + message: + warnings.tooDissimilarMessage ?? + 'These documents appear highly dissimilar. Comparison was stopped to save time.', + code: 'TOO_DISSIMILAR', + }; + self.postMessage(response); + return; + } + } + + const start = performance.now(); + try { + chunkedDiff( + baseTokens, + comparisonTokens, + batchSize, + (tokens) => { + if (tokens.length === 0) { + return; + } + const response: CompareWorkerResponse = { + type: 'chunk', + tokens, + }; + self.postMessage(response); + }, + { maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio } + ); + } catch (err) { + const error = err as Error & { __earlyStop?: boolean }; + if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) { + const response: CompareWorkerResponse = { + type: 'error', + message: + warnings.tooDissimilarMessage ?? + 'These documents appear highly dissimilar. Comparison was stopped to save time.', + code: 'TOO_DISSIMILAR', + }; + self.postMessage(response); + return; + } + throw err; + } + const durationMs = performance.now() - start; + + const response: CompareWorkerResponse = { + type: 'success', + stats: { + baseWordCount: baseTokens.length, + comparisonWordCount: comparisonTokens.length, + durationMs, + }, + }; + + self.postMessage(response); +}; diff --git a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts index 270188bc8b..da03a72158 100644 --- a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts +++ b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts @@ -1,11 +1,11 @@ -import { useMediaQuery } from '@mantine/hooks'; import { usePreferences } from '@app/contexts/PreferencesContext'; import { useAuth } from '@app/auth/UseSession'; +import { useIsMobile } from '@app/hooks/useIsMobile'; export function useShouldShowWelcomeModal(): boolean { const { preferences } = usePreferences(); const { session, loading } = useAuth(); - const isMobile = useMediaQuery("(max-width: 1024px)"); + const isMobile = useIsMobile(); // Only show welcome modal if user is authenticated (session exists) // This prevents the modal from showing on login screens when security is enabled From c8615518a6031cf66e532e2fbda09ffcc0a17bcc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:02:43 +0000 Subject: [PATCH 08/16] Addition of the Show JavaScript tool (#4877) # Description of Changes - Added the show javascript tool. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 17 +- .../public/locales/en-US/translation.json | 17 +- .../core/components/fileEditor/FileEditor.tsx | 51 ++- .../shared/filePreview/DocumentThumbnail.tsx | 9 +- .../shared/filePreview/getFileTypeIcon.tsx | 32 ++ .../tools/shared/ReviewToolStep.tsx | 42 +- .../tools/shared/createToolFlow.tsx | 15 +- .../components/tools/showJS/ShowJSView.css | 131 ++++++ .../components/tools/showJS/ShowJSView.tsx | 305 ++++++++++++++ .../src/core/components/tools/showJS/utils.ts | 382 ++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 5 +- .../hooks/tools/showJS/useShowJSOperation.ts | 137 +++++++ .../hooks/tools/showJS/useShowJSParameters.ts | 21 + frontend/src/core/styles/theme.css | 10 + frontend/src/core/tools/ShowJS.tsx | 150 +++++++ 15 files changed, 1281 insertions(+), 43 deletions(-) create mode 100644 frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.css create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.tsx create mode 100644 frontend/src/core/components/tools/showJS/utils.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts create mode 100644 frontend/src/core/tools/ShowJS.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 3e50f5cee8..4e9904f190 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -99,6 +99,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -2753,7 +2755,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", @@ -4587,6 +4596,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 8c787ee61f..10e923c904 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -98,6 +98,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -3050,7 +3052,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,manual", @@ -4909,6 +4918,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index fbafa8b896..76e6bb1a26 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext'; import { alert } from '@app/components/toast'; import { downloadBlob } from '@app/utils/downloadUtils'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; interface FileEditorProps { @@ -65,6 +66,15 @@ const FileEditor = ({ }, []); const [selectionMode, setSelectionMode] = useState(toolMode); + // Current tool (for enforcing maxFiles limits) + const { selectedTool } = useToolWorkflow(); + + // Compute effective max allowed files based on the active tool and mode + const maxAllowed = useMemo(() => { + const rawMax = selectedTool?.maxFiles; + return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; + }, [selectedTool?.maxFiles, toolMode]); + // Enable selection mode automatically in tool mode useEffect(() => { if (toolMode) { @@ -83,7 +93,10 @@ const FileEditor = ({ const localSelectedIds = contextSelectedIds; const handleSelectAllFiles = useCallback(() => { - setSelectedFiles(state.files.ids); + // Respect maxAllowed: if limited, select the last N files + const allIds = state.files.ids; + const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; + setSelectedFiles(idsToSelect); try { clearAllFileErrors(); } catch (error) { @@ -91,7 +104,7 @@ const FileEditor = ({ console.warn('Failed to clear file errors on select all:', error); } } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); const handleDeselectAllFiles = useCallback(() => { setSelectedFiles([]); @@ -131,6 +144,13 @@ const FileEditor = ({ // - HTML ZIPs stay intact // - Non-ZIP files pass through unchanged await addFiles(uploadedFiles, { selectFiles: true }); + // After auto-selection, enforce maxAllowed if needed + if (Number.isFinite(maxAllowed)) { + const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id); + if (nowSelectedIds.length > maxAllowed) { + setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + } + } showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { @@ -138,7 +158,7 @@ const FileEditor = ({ showError(errorMessage); console.error('File processing error:', err); } - }, [addFiles, showStatus, showError]); + }, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -156,24 +176,33 @@ const FileEditor = ({ newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection - // In tool mode, typically allow multiple files unless specified otherwise - const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools + // Determine max files allowed from the active tool (negative or undefined means unlimited) + const rawMax = selectedTool?.maxFiles; + const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; if (maxAllowed === 1) { + // Only one file allowed -> replace selection with the new file newSelection = [contextFileId]; } else { - // Check if we've hit the selection limit - if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); - return; + // If at capacity, drop the oldest selected and append the new one + if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { + newSelection = [...currentSelectedIds.slice(1), contextFileId]; + } else { + newSelection = [...currentSelectedIds, contextFileId]; } - newSelection = [...currentSelectedIds, contextFileId]; } } // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]); + + // Enforce maxAllowed when tool changes or when an external action sets too many selected files + useEffect(() => { + if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { + setSelectedFiles(selectedFileIds.slice(-maxAllowed)); + } + }, [maxAllowed, selectedFileIds, setSelectedFiles]); // File reordering handler for drag and drop diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index 5ace7d03f0..b60efa208b 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; -import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon'; import { StirlingFileStub } from '@app/types/fileContext'; import { PrivateContent } from '@app/components/shared/PrivateContent'; @@ -53,12 +53,7 @@ const DocumentThumbnail: React.FC = ({
- + {getFileTypeIcon(file)}
{children} diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx new file mode 100644 index 0000000000..afcacad070 --- /dev/null +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import JavascriptIcon from "@mui/icons-material/Javascript"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { detectFileExtension } from "@app/utils/fileUtils"; + +type FileLike = File | StirlingFileStub; + +/** + * Returns an appropriate file type icon for the provided file. + * - Uses the real file type and extension to decide the icon. + * - No any-casts; accepts File or StirlingFileStub. + */ +export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement { + const name = (file?.name ?? "").toLowerCase(); + const mime = (file?.type ?? "").toLowerCase(); + const ext = detectFileExtension(name); + + // JavaScript + if (ext === "js" || mime.includes("javascript")) { + return ; + } + + // PDF + if (ext === "pdf" || mime === "application/pdf") { + return ; + } + + // Fallback generic + return ; +} diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx index 30c8c906e0..2314a63025 100644 --- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,7 +14,7 @@ export interface ReviewToolStepProps { operation: ToolOperationHook; title?: string; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; isCollapsed?: boolean; onCollapsedClick?: () => void; } @@ -26,14 +26,14 @@ function ReviewStepContent({ }: { operation: ToolOperationHook; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; }) { const { t } = useTranslation(); const stepRef = useRef(null); const handleUndo = async () => { try { - onUndo(); + onUndo?.(); } catch (error) { // Error is already handled by useToolOperation, just reset loading state console.error("Undo operation failed:", error); @@ -73,17 +73,19 @@ function ReviewStepContent({ /> )} - - - + {onUndo && ( + + + + )} {operation.downloadUrl && ( + + +
+ +
+
+ {lines.map((tokens, ln) => { + if (isHidden(ln)) return null; + const end = startToEnd.get(ln); + const folded = end != null && collapsed.has(ln); + let pos = 0; + const lineMatches = matches.map((m, idx) => ({ ...m, idx })).filter((m) => m.line === ln); + const content: React.ReactNode[] = []; + tokens.forEach((tok, ti) => { + const textSeg = tok.text; + const tokenStart = pos; + const tokenEnd = pos + textSeg.length; + + if (!query || lineMatches.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + // Collect matches that intersect this token + const matchesInToken = lineMatches + .filter((m) => m.start < tokenEnd && m.end > tokenStart) + .sort((a, b) => a.start - b.start); + + if (matchesInToken.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + let cursor = 0; + const tokenCls = tok.type === "plain" ? "" : `tok-${tok.type}`; + + matchesInToken.forEach((m, mi) => { + const localStart = Math.max(0, m.start - tokenStart); + const localEnd = Math.min(textSeg.length, m.end - tokenStart); + + // before match + if (localStart > cursor) { + const beforeText = textSeg.slice(cursor, localStart); + const cls = tokenCls || undefined; + content.push( + + {beforeText} + , + ); + } + // matched piece + const hitText = textSeg.slice(localStart, localEnd); + const hitCls = + ["search-hit", m.idx === active ? "search-hit-active" : "", tokenCls].filter(Boolean).join(" ") || + undefined; + content.push( + + {hitText} + , + ); + cursor = localEnd; + }); + + // tail after last match + if (cursor < textSeg.length) { + const tailText = textSeg.slice(cursor); + const cls = tokenCls || undefined; + content.push( + + {tailText} + , + ); + } + + pos = tokenEnd; + }); + return ( +
+
+ {end != null ? ( + + ) : ( + + )} + {ln + 1} +
+
+ {content} + {folded && {"{...}"}} +
+
+ ); + })} +
+
+
+
+ + ); +}; + +export default ShowJSView; diff --git a/frontend/src/core/components/tools/showJS/utils.ts b/frontend/src/core/components/tools/showJS/utils.ts new file mode 100644 index 0000000000..c5cca24884 --- /dev/null +++ b/frontend/src/core/components/tools/showJS/utils.ts @@ -0,0 +1,382 @@ +export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain"; +export type ShowJsToken = { type: ShowJsTokenType; text: string }; + +const JS_KEYWORDS = new Set([ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "return", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + "await", + "of", +]); + +export function tokenizeToLines(src: string, keywords: Set = JS_KEYWORDS): ShowJsToken[][] { + const lines: ShowJsToken[][] = []; + let current: ShowJsToken[] = []; + let i = 0; + let inBlockCom = false; + let inLineCom = false; + let inString: '"' | "'" | "`" | null = null; + let escaped = false; + + const push = (type: ShowJsTokenType, s: string) => { + if (s) { + current.push({ type, text: s }); + } + }; + + // Named actions for readability + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + lines.push(current); + current = []; + inLineCom = false; + advance(); + }; + const handleInLineCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleBlockCommentEnd = () => { + push("com", "*/"); + inBlockCom = false; + advance(2); + }; + const handleInBlockCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleInStringChar = (ch: string) => { + push("str", ch); + if (!escaped) { + const isEscape = ch === "\\"; + const isStringClose = ch === inString; + if (isEscape) { + escaped = true; + } else if (isStringClose) { + inString = null; + } + } else { + escaped = false; + } + advance(); + }; + const startLineComment = () => { + push("com", "//"); + inLineCom = true; + advance(2); + }; + const startBlockComment = () => { + push("com", "/*"); + inBlockCom = true; + advance(2); + }; + const startString = (ch: '"' | "'" | "`") => { + inString = ch; + push("str", ch); + advance(); + }; + const pushNumberToken = () => { + let j = i + 1; + const isNumberContinuation = (c: string) => /[0-9._xobA-Fa-f]/.test(c); + while (j < src.length && isNumberContinuation(src[j])) { + j++; + } + push("num", src.slice(i, j)); + i = j; + }; + const pushIdentifierToken = () => { + let j = i + 1; + const isIdentContinuation = (c: string) => /[A-Za-z0-9_$]/.test(c); + while (j < src.length && isIdentContinuation(src[j])) { + j++; + } + const id = src.slice(i, j); + const isKeyword = keywords.has(id); + push(isKeyword ? "kw" : "plain", id); + i = j; + }; + const pushPlainChar = (ch: string) => { + push("plain", ch); + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + + // Named conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && next === "/"; + const isBlockCommentStart = ch === "/" && next === "*"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isDigit = /[0-9]/.test(ch); + const isIdentifierStart = /[A-Za-z_$]/.test(ch); + + if (isNewline) { + handleNewline(); + continue; + } + + if (inLineCom) { + handleInLineCommentChar(ch); + continue; + } + + if (inBlockCom) { + const isBlockCommentEnd = ch === "*" && next === "/"; + if (isBlockCommentEnd) { + handleBlockCommentEnd(); + continue; + } + handleInBlockCommentChar(ch); + continue; + } + + if (inString) { + handleInStringChar(ch); + continue; + } + + if (isLineCommentStart) { + startLineComment(); + continue; + } + + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + + if (isDigit) { + pushNumberToken(); + continue; + } + + if (isIdentifierStart) { + pushIdentifierToken(); + continue; + } + + pushPlainChar(ch); + } + + lines.push(current); + return lines; +} + +export function computeBlocks(src: string): Array<{ start: number; end: number }> { + const res: Array<{ start: number; end: number }> = []; + let i = 0; + let line = 0; + let inBlock = false; + let inLine = false; + let str: '"' | "'" | "`" | null = null; + let esc = false; + const stack: number[] = []; + + // Actions + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + line++; + inLine = false; + advance(); + }; + const startLineComment = () => { + inLine = true; + advance(2); + }; + const startBlockComment = () => { + inBlock = true; + advance(2); + }; + const endBlockComment = () => { + inBlock = false; + advance(2); + }; + const startString = (delim: '"' | "'" | "`") => { + str = delim; + advance(); + }; + const handleStringChar = (ch: string) => { + if (!esc) { + const isEscape = ch === "\\"; + const isClose = ch === str; + if (isEscape) { + esc = true; + } else if (isClose) { + str = null; + } + } else { + esc = false; + } + advance(); + }; + const pushOpenBrace = () => { + stack.push(line); + advance(); + }; + const handleCloseBrace = () => { + const s = stack.pop(); + if (s != null && line > s) { + res.push({ start: s, end: line }); + } + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const nx = src[i + 1]; + + // Conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && nx === "/"; + const isBlockCommentStart = ch === "/" && nx === "*"; + const isBlockCommentEnd = ch === "*" && nx === "/"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isOpenBrace = ch === "{"; + const isCloseBrace = ch === "}"; + + if (isNewline) { + handleNewline(); + continue; + } + if (inLine) { + advance(); + continue; + } + if (inBlock) { + if (isBlockCommentEnd) { + endBlockComment(); + } else { + advance(); + } + continue; + } + if (str) { + handleStringChar(ch); + continue; + } + if (isLineCommentStart) { + startLineComment(); + continue; + } + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + if (isOpenBrace) { + pushOpenBrace(); + continue; + } + if (isCloseBrace) { + handleCloseBrace(); + continue; + } + advance(); + } + return res; +} + +export function computeSearchMatches( + lines: ShowJsToken[][], + query: string, +): Array<{ line: number; start: number; end: number }> { + if (!query) { + return []; + } + const q = query.toLowerCase(); + const list: Array<{ line: number; start: number; end: number }> = []; + lines.forEach((toks, ln) => { + const raw = toks.map((t) => t.text).join(""); + let idx = 0; + while (true) { + const pos = raw.toLowerCase().indexOf(q, idx); + if (pos === -1) { + break; + } + list.push({ + line: ln, + start: pos, + end: pos + q.length, + }); + idx = pos + Math.max(1, q.length); + } + }); + return list; +} + +export async function copyTextToClipboard(text: string, fallbackElement?: HTMLElement | null): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(text || ""); + return true; + } + } catch { + // fall through to fallback + } + if (typeof document === "undefined" || !fallbackElement) return false; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(fallbackElement); + selection?.removeAllRanges(); + selection?.addRange(range); + try { + document.execCommand("copy"); + return true; + } finally { + selection?.removeAllRanges(); + } +} + +export function triggerDownload(url: string, filename: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 82c02b2330..bcd11478b7 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -121,6 +121,7 @@ import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlank import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings"; import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings"; import ValidateSignature from "@app/tools/ValidateSignature"; +import ShowJS from "@app/tools/ShowJS"; import Automate from "@app/tools/Automate"; import Compare from "@app/tools/Compare"; import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats"; @@ -714,10 +715,12 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { showJS: { icon: , name: t("home.showJS.title", "Show JavaScript"), - component: null, + component: ShowJS, description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, + maxFiles: 1, + endpoints: ["show-javascript"], synonyms: getSynonyms(t, "showJS"), supportsAutomate: false, automationSettings: null diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts new file mode 100644 index 0000000000..081fc2723c --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts @@ -0,0 +1,137 @@ +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import apiClient from '@app/services/apiClient'; +import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import type { StirlingFile } from '@app/types/fileContext'; +import { extractErrorMessage } from '@app/utils/toolErrorHandler'; +import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import type { ResponseType } from 'axios'; + +export interface ShowJSOperationHook extends ToolOperationHook { + scriptText: string | null; +} + +export const useShowJSOperation = (): ShowJSOperationHook => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [scriptText, setScriptText] = useState(null); + + const cancelRequested = useRef(false); + const previousUrl = useRef(null); + + const cleanupDownloadUrl = useCallback(() => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + previousUrl.current = null; + } + }, []); + + const resetResults = useCallback(() => { + cancelRequested.current = false; + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + }, [cleanupDownloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + const executeOperation = useCallback( + async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => { + if (selectedFiles.length === 0) { + setErrorMessage(t('noFileSelected', 'No files selected')); + return; + } + + cancelRequested.current = false; + setIsLoading(true); + setStatus(t('showJS.processing', 'Extracting JavaScript...')); + setErrorMessage(null); + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + + try { + const file = selectedFiles[0]; + const formData = new FormData(); + formData.append('fileInput', file); + + const response = await apiClient.post('/api/v1/misc/show-javascript', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + responseType: 'text' as ResponseType, + transformResponse: [(data) => data], + }); + + const text: string = typeof response.data === 'string' ? response.data : ''; + setScriptText(text); + + // Optional: prepare a downloadable file + const outFile = new File([text], (file.name?.replace(/\.[^.]+$/, '') || 'extracted') + '.js', { + type: 'application/javascript', + }); + setFiles([outFile]); + const blobUrl = URL.createObjectURL(outFile); + previousUrl.current = blobUrl; + setDownloadUrl(blobUrl); + setDownloadFilename(outFile.name); + + setStatus(t('showJS.done', 'JavaScript extracted')); + } catch (error: unknown) { + setErrorMessage(extractErrorMessage(error)); + setStatus(''); + } finally { + setIsLoading(false); + } + }, + [t, cleanupDownloadUrl] + ); + + const cancelOperation = useCallback(() => { + cancelRequested.current = true; + setIsLoading(false); + setStatus(t('operationCancelled', 'Operation cancelled')); + }, [t]); + + const undoOperation = useCallback(async () => { + // No-op for this tool + setStatus(t('nothingToUndo', 'Nothing to undo')); + }, [t]); + + return { + // State (align with ToolOperationHook) + files, + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl, + downloadFilename, + isLoading, + status, + errorMessage, + progress: null, + + // Custom state + scriptText, + + // Actions + executeOperation, + resetResults, + clearError, + cancelOperation, + undoOperation, + }; +}; + + diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts new file mode 100644 index 0000000000..67bcaca568 --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts @@ -0,0 +1,21 @@ +import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; +import { BaseParameters } from '@app/types/parameters'; + +export interface ShowJSParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: ShowJSParameters = { + // No parameters needed +}; + + +export type ShowJSParametersHook = BaseParametersHook; + +export const useShowJSParameters = (): ShowJSParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'show-javascript', + }); +}; + diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 22c90d090a..3051ced06e 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -302,6 +302,11 @@ --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; + /* Code token colors (light mode) */ + --code-kw-color: #1d4ed8; /* blue-700 */ + --code-str-color: #16a34a; /* green-600 */ + --code-num-color: #4338ca; /* indigo-700 */ + --code-com-color: #6b7280; /* gray-500 */ /* Compare tool specific colors - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); @@ -534,6 +539,11 @@ --modal-content-bg: #2A2F36; --modal-header-border: rgba(255, 255, 255, 0.08); + /* Code token colors (dark mode - Cursor-like) */ + --code-kw-color: #C792EA; /* purple */ + --code-str-color: #C3E88D; /* green */ + --code-num-color: #F78C6C; /* orange */ + --code-com-color: #697098; /* muted gray-blue */ /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45); --compare-upload-dropzone-border: rgba(75, 85, 99, 0.6); diff --git a/frontend/src/core/tools/ShowJS.tsx b/frontend/src/core/tools/ShowJS.tsx new file mode 100644 index 0000000000..234298d9b6 --- /dev/null +++ b/frontend/src/core/tools/ShowJS.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import type { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { useShowJSParameters, defaultParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import { useShowJSOperation, type ShowJSOperationHook } from '@app/hooks/tools/showJS/useShowJSOperation'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import ShowJSView from '@app/components/tools/showJS/ShowJSView'; +import { useFileSelection } from '@app/contexts/file/fileHooks'; + +const ShowJS = (props: BaseToolProps) => { + const { t } = useTranslation(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + } = useToolWorkflow(); + + const VIEW_ID = 'showJSView'; + const WORKBENCH_ID = 'custom:showJS' as const; + const viewIcon = useMemo(() => , []); + + const base = useBaseTool('showJS', useShowJSParameters, useShowJSOperation, props, { minFiles: 1 }); + const operation = base.operation as ShowJSOperationHook; + const hasResults = Boolean(operation.scriptText); + const { clearSelections } = useFileSelection(); + + useEffect(() => { + registerCustomWorkbenchView({ + id: VIEW_ID, + workbenchId: WORKBENCH_ID, + label: t('showJS.view.title', 'JavaScript'), + icon: viewIcon, + component: ({ data }) => , + }); + + return () => { + clearCustomWorkbenchViewData(VIEW_ID); + unregisterCustomWorkbenchView(VIEW_ID); + }; + }, [clearCustomWorkbenchViewData, registerCustomWorkbenchView, t, unregisterCustomWorkbenchView, viewIcon]); + + const lastShownRef = useRef(null); + + useEffect(() => { + if (operation.scriptText) { + setCustomWorkbenchViewData(VIEW_ID, { + scriptText: operation.scriptText, + downloadUrl: operation.downloadUrl, + downloadFilename: operation.downloadFilename, + }); + const marker = operation.scriptText.length; + const isNew = lastShownRef.current == null || marker !== lastShownRef.current; + if (isNew) { + lastShownRef.current = marker; + if (navigationState.selectedTool === 'showJS' && navigationState.workbench !== WORKBENCH_ID) { + navigationActions.setWorkbench(WORKBENCH_ID); + } + } + } else { + clearCustomWorkbenchViewData(VIEW_ID); + lastShownRef.current = null; + } + }, [ + clearCustomWorkbenchViewData, + navigationActions, + navigationState.selectedTool, + navigationState.workbench, + operation.scriptText, + setCustomWorkbenchViewData, + ]); + + useEffect(() => { + if ((base.selectedFiles?.length ?? 0) === 0) { + try { base.operation.resetResults(); } catch { /* noop */ } + try { clearCustomWorkbenchViewData(VIEW_ID); } catch { /* noop */ } + if (navigationState.workbench === WORKBENCH_ID) { + try { navigationActions.setWorkbench('fileEditor'); } catch { /* noop */ } + } + lastShownRef.current = null; + } + }, [ + base.selectedFiles?.length, + base.operation, + clearCustomWorkbenchViewData, + navigationActions, + navigationState.workbench, + ]); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: false, + }, + steps: [], + executeButton: { + text: hasResults ? t('back', 'Back') : t('showJS.submit', 'Extract JavaScript'), + loadingText: t('loading', 'Loading...'), + onClick: hasResults + ? async () => { + // Clear results and deselect files so user can pick another file + try { + await base.operation.resetResults(); + } catch { /* noop */ } + try { + clearSelections(); + } catch { /* noop */ } + // Close the custom JS view and send user back to file manager to pick another file + try { + clearCustomWorkbenchViewData(VIEW_ID); + } catch { /* noop */ } + try { + navigationActions.setWorkbench('fileEditor'); + } catch { /* noop */ } + } + : base.handleExecute, + disabled: hasResults + ? false + : ( + !base.hasFiles || + (base.selectedFiles?.length ?? 0) !== 1 || + base.operation.isLoading || + base.endpointLoading || + base.endpointEnabled === false + ), + isVisible: true, + }, + review: { + isVisible: hasResults, + operation: base.operation, + title: t('showJS.results', 'Result'), + onUndo: undefined, + }, + }); +}; + +const ShowJSTool = ShowJS as ToolComponent; +ShowJSTool.tool = () => useShowJSOperation; +ShowJSTool.getDefaultParameters = () => ({ ...defaultParameters }); + +export default ShowJSTool; + + From 74a1438c212e92892377cbdb4f8f4570e91227df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:18:15 +0100 Subject: [PATCH 09/16] [V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Introduced a user confirmation dialog for extracting ZIP files with more than **20 files**. - Created `useZipConfirmation` hook to handle confirmation dialog logic and state. - Implemented `ZipWarningModal` component to display the confirmation dialog. - Updated `zipFileService` to count files in ZIP and trigger confirmation callback for large files. - Integrated confirmation flow into `FileContext` and `useToolResources`. - Added translations for new ZIP warning dialog messages. This pull request introduces a user confirmation dialog when attempting to extract large ZIP files (**over 20 files**), improving safety and user experience by preventing accidental extraction of very large archives. The implementation includes a reusable confirmation modal, a custom hook to handle dialog state and resolution, and updates to the ZIP extraction logic to support this workflow. **User Experience Improvements** * Added a new localized warning dialog (`ZipWarningModal`) that prompts users for confirmation when extracting ZIP files containing more than 20 files. This dialog displays the ZIP file name, file count, and offers "Cancel" and "Extract" actions, with responsive layouts for desktop and mobile **ZIP Extraction Workflow Enhancements** * Updated the ZIP extraction logic in `ZipFileService` to count the number of files in a ZIP and invoke a confirmation callback if the file count exceeds the threshold. Extraction proceeds only if the user confirms; otherwise, the ZIP remains unextracted. * Added a new hook (`useZipConfirmation`) to manage the confirmation dialog’s state and provide a promise-based API for requesting user confirmation. **Integration with Application State** * Integrated the confirmation workflow into `FileContext`, passing the confirmation function into ZIP extraction calls and rendering the modal dialog at the appropriate time. * Updated relevant interfaces and method signatures to support the optional confirmation callback for large ZIP extractions throughout the codebase. image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 6 ++ .../components/shared/ZipWarningModal.tsx | 96 +++++++++++++++++++ frontend/src/core/contexts/FileContext.tsx | 18 +++- .../src/core/contexts/file/fileActions.ts | 5 +- .../hooks/tools/shared/useToolResources.ts | 9 +- frontend/src/core/hooks/useZipConfirmation.ts | 75 +++++++++++++++ frontend/src/core/services/zipFileService.ts | 44 ++++++--- .../tests/convert/ConvertIntegration.test.tsx | 17 ++-- .../ConvertSmartDetectionIntegration.test.tsx | 17 ++-- 9 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 frontend/src/core/components/shared/ZipWarningModal.tsx create mode 100644 frontend/src/core/hooks/useZipConfirmation.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4e9904f190..4b33f60341 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -35,6 +35,12 @@ "discardChanges": "Discard & Leave", "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", + "zipWarning": { + "title": "Large ZIP File", + "message": "This ZIP contains {{count}} files. Extract anyway?", + "cancel": "Cancel", + "confirm": "Extract" + }, "language": { "direction": "ltr" }, diff --git a/frontend/src/core/components/shared/ZipWarningModal.tsx b/frontend/src/core/components/shared/ZipWarningModal.tsx new file mode 100644 index 0000000000..909cf1b31b --- /dev/null +++ b/frontend/src/core/components/shared/ZipWarningModal.tsx @@ -0,0 +1,96 @@ +import { Modal, Text, Button, Group, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import CancelIcon from "@mui/icons-material/Cancel"; +import { CSSProperties } from "react"; + +interface ZipWarningModalProps { + opened: boolean; + onConfirm: () => void; + onCancel: () => void; + fileCount: number; + zipFileName: string; +} + +const WARNING_ICON_STYLE: CSSProperties = { + fontSize: 36, + display: 'block', + margin: '0 auto 8px', + color: 'var(--mantine-color-blue-6)' +}; + +const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => { + const { t } = useTranslation(); + + return ( + + + + + {zipFileName} + + + {t("zipWarning.message", { + count: fileCount, + defaultValue: "This ZIP contains {{count}} files. Extract anyway?" + })} + + + + {/* Desktop layout: centered buttons */} + + + + + + {/* Mobile layout: vertical stack */} + + + + + + ); +}; + +export default ZipWarningModal; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 60d95b6767..56f1dcabde 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -31,6 +31,8 @@ import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createF import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; +import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; +import ZipWarningModal from '@app/components/shared/ZipWarningModal'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -52,6 +54,9 @@ function FileContextInner({ const stateRef = useRef(state); stateRef.current = state; + // ZIP confirmation dialog + const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation(); + // Create lifecycle manager const lifecycleManagerRef = useRef(null); if (!lifecycleManagerRef.current) { @@ -86,7 +91,9 @@ function FileContextInner({ ...options, // For direct file uploads: ALWAYS unzip (except HTML ZIPs) // skipAutoUnzip bypasses preference checks - HTML detection still applies - skipAutoUnzip: true + skipAutoUnzip: true, + // Provide confirmation callback for large ZIP files + confirmLargeExtraction: requestConfirmation }, stateRef, filesRef, @@ -101,7 +108,7 @@ function FileContextInner({ } return stirlingFiles; - }, [enablePersistence]); + }, [enablePersistence, requestConfirmation]); const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { // StirlingFileStubs preserve all metadata - perfect for FileManager use case! @@ -237,6 +244,13 @@ function FileContextInner({ {children} + ); diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 0a37134f12..d209781c89 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -186,6 +186,7 @@ interface AddFileOptions { autoUnzip?: boolean; autoUnzipFileLimit?: number; skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs. + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; // Optional callback to confirm extraction of large ZIP files } /** @@ -219,6 +220,7 @@ export async function addFiles( const autoUnzip = options.autoUnzip ?? true; // Default to true const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit const skipAutoUnzip = options.skipAutoUnzip ?? false; + const confirmLargeExtraction = options.confirmLargeExtraction; for (const file of files) { // Check if file is a ZIP @@ -238,7 +240,8 @@ export async function addFiles( const extractedFiles = await zipFileService.extractWithPreferences(file, { autoUnzip, autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); if (extractedFiles.length === 1 && extractedFiles[0] === file) { diff --git a/frontend/src/core/hooks/tools/shared/useToolResources.ts b/frontend/src/core/hooks/tools/shared/useToolResources.ts index 086a00f49c..a80c2c298b 100644 --- a/frontend/src/core/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/core/hooks/tools/shared/useToolResources.ts @@ -83,12 +83,17 @@ export const useToolResources = () => { return results; }, []); - const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { + const extractZipFiles = useCallback(async ( + zipBlob: Blob, + skipAutoUnzip = false, + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise + ): Promise => { try { return await zipFileService.extractWithPreferences(zipBlob, { autoUnzip: preferences.autoUnzip, autoUnzipFileLimit: preferences.autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); } catch (error) { console.error('useToolResources.extractZipFiles - Error:', error); diff --git a/frontend/src/core/hooks/useZipConfirmation.ts b/frontend/src/core/hooks/useZipConfirmation.ts new file mode 100644 index 0000000000..e23a2ce80d --- /dev/null +++ b/frontend/src/core/hooks/useZipConfirmation.ts @@ -0,0 +1,75 @@ +import { useState, useCallback, useRef } from 'react'; + +interface ZipConfirmationState { + opened: boolean; + fileCount: number; + fileName: string; +} + +/** + * Hook to manage ZIP warning confirmation dialog + * Returns state and handlers for the confirmation dialog + * Uses useRef to avoid recreating callbacks on every state change + */ +export const useZipConfirmation = () => { + const [confirmationState, setConfirmationState] = useState({ + opened: false, + fileCount: 0, + fileName: '', + }); + + // Store resolve function in ref to avoid callback recreation + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + /** + * Request confirmation from user for extracting a large ZIP file + * Returns a Promise that resolves to true if user confirms, false if cancelled + */ + const requestConfirmation = useCallback((fileCount: number, fileName: string): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setConfirmationState({ + opened: true, + fileCount, + fileName, + }); + }); + }, []); + + /** + * Handle user confirmation - extract the ZIP + */ + const handleConfirm = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(true); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + /** + * Handle user cancellation - keep ZIP as-is + */ + const handleCancel = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(false); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + return { + confirmationState, + requestConfirmation, + handleConfirm, + handleCancel, + }; +}; diff --git a/frontend/src/core/services/zipFileService.ts b/frontend/src/core/services/zipFileService.ts index f7f157283f..f4ed398652 100644 --- a/frontend/src/core/services/zipFileService.ts +++ b/frontend/src/core/services/zipFileService.ts @@ -44,6 +44,9 @@ export class ZipFileService { private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit + // Warn user when extracting ZIP with more than this many files + public static readonly ZIP_WARNING_THRESHOLD = 20; + // ZIP file validation constants private static readonly VALID_ZIP_TYPES = [ 'application/zip', @@ -361,31 +364,35 @@ export class ZipFileService { /** * Determine if a ZIP file should be extracted based on user preferences + * Returns both the extraction decision and file count to avoid redundant ZIP parsing * * @param zipBlob - The ZIP file to check * @param autoUnzip - User preference for auto-unzipping * @param autoUnzipFileLimit - Maximum number of files to auto-extract * @param skipAutoUnzip - Bypass preference check (for automation) - * @returns true if the ZIP should be extracted, false otherwise + * @returns Object with shouldExtract flag and fileCount */ async shouldUnzip( zipBlob: Blob | File, autoUnzip: boolean, autoUnzipFileLimit: number, skipAutoUnzip: boolean = false - ): Promise { + ): Promise<{ shouldExtract: boolean; fileCount: number }> { try { - // Automation always extracts + // Automation always extracts - but still need to count files for warning if (skipAutoUnzip) { - return true; + const zip = new JSZip(); + const zipContents = await zip.loadAsync(zipBlob); + const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; + return { shouldExtract: true, fileCount }; } // Check if auto-unzip is enabled if (!autoUnzip) { - return false; + return { shouldExtract: false, fileCount: 0 }; } - // Load ZIP and count files + // Load ZIP and count files (single parse) const zip = new JSZip(); const zipContents = await zip.loadAsync(zipBlob); @@ -393,20 +400,22 @@ export class ZipFileService { const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; // Only extract if within limit - return fileCount <= autoUnzipFileLimit; + return { + shouldExtract: fileCount <= autoUnzipFileLimit, + fileCount + }; } catch (error) { console.error('Error checking shouldUnzip:', error); // On error, default to not extracting (safer) - return false; + return { shouldExtract: false, fileCount: 0 }; } } - /** * Extract files from ZIP with HTML detection and preference checking - * This is the unified method that handles the common pattern of: * 1. Check for HTML files → keep zipped if present * 2. Check user preferences → respect autoUnzipFileLimit - * 3. Extract files if appropriate + * 3. Show warning for large ZIPs (>20 files) if callback provided + * 4. Extract files if appropriate * * @param zipBlob - The ZIP blob to process * @param options - Extraction options @@ -418,6 +427,7 @@ export class ZipFileService { autoUnzip: boolean; autoUnzipFileLimit: number; skipAutoUnzip?: boolean; + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; } ): Promise { try { @@ -432,8 +442,8 @@ export class ZipFileService { return [zipFile]; } - // Check if we should extract based on preferences - const shouldExtract = await this.shouldUnzip( + // Check if we should extract based on preferences (returns both decision and count) + const { shouldExtract, fileCount } = await this.shouldUnzip( zipBlob, options.autoUnzip, options.autoUnzipFileLimit, @@ -444,6 +454,14 @@ export class ZipFileService { return [zipFile]; } + // Warn user if ZIP is large (fileCount already obtained from shouldUnzip) + if (fileCount > ZipFileService.ZIP_WARNING_THRESHOLD && options.confirmLargeExtraction) { + const userConfirmed = await options.confirmLargeExtraction(fileCount, zipFile.name); + if (!userConfirmed) { + return [zipFile]; // User cancelled, keep ZIP as-is + } + } + // Extract all files const extractionResult = await this.extractAllFiles(zipFile); return extractionResult.success ? extractionResult.extractedFiles : [zipFile]; diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 5d0d899d7e..80e8760c7a 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -20,6 +20,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from '@app/i18n/config'; import { createTestStirlingFile } from '@app/tests/utils/testFileHelpers'; import { StirlingFile } from '@app/types/fileContext'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -88,13 +89,15 @@ const createPDFFile = (): StirlingFile => { // Test wrapper component const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool Integration Tests', () => { diff --git a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index a47bb26fc5..9994d36a73 100644 --- a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -15,6 +15,7 @@ import i18n from '@app/i18n/config'; import { detectFileExtension } from '@app/utils/fileUtils'; import { FIT_OPTIONS } from '@app/constants/convertConstants'; import { createTestStirlingFile, createTestFilesWithId } from '@app/tests/utils/testFileHelpers'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -76,13 +77,15 @@ vi.mock('../../services/thumbnailGenerationService', () => ({ })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool - Smart Detection Integration Tests', () => { From a05c5a53c7a2e16cd6192d389a4c70da221c1f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:27:41 +0100 Subject: [PATCH 10/16] [V2] feat(convert): add support for CBZ to PDF and PDF to CBZ conversion (#4831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Implemented `ConvertFromCbzSettings` component for CBZ to PDF options - Added `ConvertToCbzSettings` component for PDF to CBZ options - Extended conversion constants with new CBZ-related formats and mappings - Updated `ConvertSettings` to include CBZ-specific configuration forms - Updated `useConvertOperation` to handle CBZ conversion parameters For backend reference see this PR: #4472 This pull request adds support for converting between CBZ (Comic Book Zip) and PDF formats in the frontend conversion tool. It introduces new UI components for CBZ-to-PDF and PDF-to-CBZ conversion options, updates the conversion parameters and form data logic, and integrates these formats into the available conversion options and endpoints. **CBZ/PDF Conversion Support:** * Added `ConvertFromCbzSettings` and `ConvertToCbzSettings` components to provide user options for CBZ-to-PDF and PDF-to-CBZ conversions, including "Optimize for ebook" and DPI settings. **Conversion Logic and Parameters:** * Extended the `ConvertParameters` interface and default parameters to include `cbzOptions` (for CBZ-to-PDF) and `cbzOutputOptions` (for PDF-to-CBZ). **Conversion Matrix and Endpoint Integration:** * Added CBZ to the supported "from" and "to" format lists, updated the conversion matrix to allow CBZ-to-PDF and PDF-to-CBZ, and mapped the new conversions to appropriate API endpoints. * Updated URL mapping to recognize `/cbz-to-pdf` and `/pdf-to-cbz` as conversion tool routes. ### Front-end image image image image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../public/locales/en-GB/translation.json | 6 +- .../fileEditor/FileEditorThumbnail.tsx | 9 ++- .../tools/convert/ConvertFromCbzSettings.tsx | 36 ++++++++++ .../tools/convert/ConvertSettings.tsx | 38 ++++++++++ .../tools/convert/ConvertToCbzSettings.tsx | 39 ++++++++++ .../src/core/constants/convertConstants.ts | 13 +++- .../core/constants/convertSupportedFornats.ts | 2 +- .../tools/convert/useConvertOperation.ts | 6 +- .../tools/convert/useConvertParameters.ts | 12 ++++ .../tests/convert/ConvertIntegration.test.tsx | 72 +++++++++++++++++++ frontend/src/core/utils/urlMapping.ts | 2 + 11 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx create mode 100644 frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4b33f60341..aae5bbc9ed 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1105,7 +1105,11 @@ "markdown": "Markdown", "textRtf": "Text/RTF", "grayscale": "Greyscale", - "errorConversion": "An error occurred while converting the file." + "errorConversion": "An error occurred while converting the file.", + "cbzOptions": "CBZ to PDF Options", + "optimizeForEbook": "Optimize PDF for ebook readers (uses Ghostscript)", + "cbzOutputOptions": "PDF to CBZ Options", + "cbzDpi": "DPI for image rendering" }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index b51152af1a..e268b941ce 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -93,6 +93,13 @@ const FileEditorThumbnail = ({ return (m?.[1] || '').toUpperCase(); }, [file.name]); + const extLower = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toLowerCase(); + }, [file.name]); + + const isCBZ = extLower === 'cbz'; + const pageLabel = useMemo( () => pageCount > 0 @@ -206,7 +213,7 @@ const FileEditorThumbnail = ({ alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); } }, - hidden: !isZipFile || !onUnzipFile, + hidden: !isZipFile || !onUnzipFile || isCBZ, }, { id: 'close', diff --git a/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx b/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx new file mode 100644 index 0000000000..babb202559 --- /dev/null +++ b/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx @@ -0,0 +1,36 @@ +import { Stack, Text, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; + +interface ConvertFromCbzSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; + disabled?: boolean; +} + +const ConvertFromCbzSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromCbzSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t('convert.cbzOptions', 'CBZ to PDF Options')}: + + onParameterChange('cbzOptions', { + ...parameters.cbzOptions, + optimizeForEbook: event.currentTarget.checked + })} + disabled={disabled} + data-testid="optimize-ebook-checkbox" + /> + + ); +}; + +export default ConvertFromCbzSettings; diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx index d332f888e3..d9727b0789 100644 --- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx @@ -13,6 +13,8 @@ import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImage import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings"; import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings"; +import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; +import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; import { @@ -118,6 +120,12 @@ const ConvertSettings = ({ onParameterChange('pdfaOptions', { outputFormat: 'pdfa-1', }); + onParameterChange('cbzOptions', { + optimizeForEbook: false, + }); + onParameterChange('cbzOutputOptions', { + dpi: 150, + }); onParameterChange('isSmartDetection', false); onParameterChange('smartDetectionType', 'none'); }; @@ -180,6 +188,12 @@ const ConvertSettings = ({ onParameterChange('pdfaOptions', { outputFormat: 'pdfa-1', }); + onParameterChange('cbzOptions', { + optimizeForEbook: false, + }); + onParameterChange('cbzOutputOptions', { + dpi: 150, + }); }; @@ -293,6 +307,30 @@ const ConvertSettings = ({ )} + {/* CBZ to PDF options */} + {parameters.fromExtension === 'cbz' && parameters.toExtension === 'pdf' && ( + <> + + + + )} + + {/* PDF to CBZ options */} + {parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbz' && ( + <> + + + + )} + {/* PDF to PDF/A options */} {parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && ( <> diff --git a/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx b/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx new file mode 100644 index 0000000000..774219ea57 --- /dev/null +++ b/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx @@ -0,0 +1,39 @@ +import { Stack, Text, NumberInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; + +interface ConvertToCbzSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; + disabled?: boolean; +} + +const ConvertToCbzSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertToCbzSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t('convert.cbzOutputOptions', 'PDF to CBZ Options')}: + + typeof val === 'number' && onParameterChange('cbzOutputOptions', { + ...parameters.cbzOutputOptions, + dpi: val + })} + min={72} + max={600} + step={1} + disabled={disabled} + /> + + ); +}; + +export default ConvertToCbzSettings; diff --git a/frontend/src/core/constants/convertConstants.ts b/frontend/src/core/constants/convertConstants.ts index 5978d523b1..0dc6e37263 100644 --- a/frontend/src/core/constants/convertConstants.ts +++ b/frontend/src/core/constants/convertConstants.ts @@ -21,6 +21,8 @@ export const CONVERSION_ENDPOINTS = { 'office-pdf': '/api/v1/convert/file/pdf', 'pdf-image': '/api/v1/convert/pdf/img', 'image-pdf': '/api/v1/convert/img/pdf', + 'cbz-pdf': '/api/v1/convert/cbz/pdf', + 'pdf-cbz': '/api/v1/convert/pdf/cbz', 'pdf-office-word': '/api/v1/convert/pdf/word', 'pdf-office-presentation': '/api/v1/convert/pdf/presentation', 'pdf-office-text': '/api/v1/convert/pdf/text', @@ -38,6 +40,8 @@ export const ENDPOINT_NAMES = { 'office-pdf': 'file-to-pdf', 'pdf-image': 'pdf-to-img', 'image-pdf': 'img-to-pdf', + 'cbz-pdf': 'cbz-to-pdf', + 'pdf-cbz': 'pdf-to-cbz', 'pdf-office-word': 'pdf-to-word', 'pdf-office-presentation': 'pdf-to-presentation', 'pdf-office-text': 'pdf-to-text', @@ -57,6 +61,7 @@ export const FROM_FORMAT_OPTIONS = [ { value: 'any', label: 'Any', group: 'Multiple Files' }, { value: 'image', label: 'Images', group: 'Multiple Files' }, { value: 'pdf', label: 'PDF', group: 'Document' }, + { value: 'cbz', label: 'CBZ', group: 'Archive' }, { value: 'docx', label: 'DOCX', group: 'Document' }, { value: 'doc', label: 'DOC', group: 'Document' }, { value: 'odt', label: 'ODT', group: 'Document' }, @@ -87,6 +92,7 @@ export const TO_FORMAT_OPTIONS = [ { value: 'pdfa', label: 'PDF/A', group: 'Document' }, { value: 'docx', label: 'DOCX', group: 'Document' }, { value: 'odt', label: 'ODT', group: 'Document' }, + { value: 'cbz', label: 'CBZ', group: 'Archive' }, { value: 'csv', label: 'CSV', group: 'Spreadsheet' }, { value: 'pptx', label: 'PPTX', group: 'Presentation' }, { value: 'odp', label: 'ODP', group: 'Presentation' }, @@ -107,7 +113,8 @@ export const TO_FORMAT_OPTIONS = [ export const CONVERSION_MATRIX: Record = { 'any': ['pdf'], // Mixed files always convert to PDF 'image': ['pdf'], // Multiple images always convert to PDF - 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'], + 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz'], + 'cbz': ['pdf'], 'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'], 'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'], 'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'], @@ -130,8 +137,10 @@ export const EXTENSION_TO_ENDPOINT: Record> = { 'csv': 'pdf-to-csv', 'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown', 'html': 'pdf-to-html', 'xml': 'pdf-to-xml', - 'pdfa': 'pdf-to-pdfa' + 'pdfa': 'pdf-to-pdfa', + 'cbz': 'pdf-to-cbz' }, + 'cbz': { 'pdf': 'cbz-to-pdf' }, 'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' }, 'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' }, 'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' }, diff --git a/frontend/src/core/constants/convertSupportedFornats.ts b/frontend/src/core/constants/convertSupportedFornats.ts index b9bea32279..86138c4e68 100644 --- a/frontend/src/core/constants/convertSupportedFornats.ts +++ b/frontend/src/core/constants/convertSupportedFornats.ts @@ -13,7 +13,7 @@ export const CONVERT_SUPPORTED_FORMATS = [ // Email formats 'eml', // Archive formats - 'zip', + 'zip', 'cbz', // Other 'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf', ]; diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index 28080407d6..9134c9db4a 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -39,7 +39,7 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile formData.append("fileInput", file); }); - const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters; + const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions } = parameters; if (isImageFormat(toExtension)) { formData.append("imageFormat", toExtension); @@ -67,6 +67,10 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile formData.append("outputFormat", pdfaOptions.outputFormat); } else if (fromExtension === 'pdf' && toExtension === 'csv') { formData.append("pageNumbers", "all"); + } else if (fromExtension === 'cbz' && toExtension === 'pdf') { + formData.append("optimizeForEbook", (cbzOptions?.optimizeForEbook ?? false).toString()); + } else if (fromExtension === 'pdf' && toExtension === 'cbz') { + formData.append("dpi", (cbzOutputOptions?.dpi ?? 150).toString()); } return formData; diff --git a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts index c9c19176e3..eaa05d5718 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts @@ -36,6 +36,12 @@ export interface ConvertParameters extends BaseParameters { pdfaOptions: { outputFormat: string; }; + cbzOptions: { + optimizeForEbook: boolean; + }; + cbzOutputOptions: { + dpi: number; + }; isSmartDetection: boolean; smartDetectionType: 'mixed' | 'images' | 'web' | 'none'; } @@ -69,6 +75,12 @@ export const defaultParameters: ConvertParameters = { pdfaOptions: { outputFormat: 'pdfa-1', }, + cbzOptions: { + optimizeForEbook: false, + }, + cbzOutputOptions: { + dpi: 150, + }, isSmartDetection: false, smartDetectionType: 'none', }; diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 80e8760c7a..bb2f0bcf11 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -151,6 +151,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -218,6 +224,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -263,6 +275,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -317,6 +335,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -375,6 +399,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -431,6 +461,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -483,6 +519,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -532,6 +574,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -583,6 +631,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -631,6 +685,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -685,6 +745,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -738,6 +804,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; diff --git a/frontend/src/core/utils/urlMapping.ts b/frontend/src/core/utils/urlMapping.ts index c76adf856c..ba6fe1aaa7 100644 --- a/frontend/src/core/utils/urlMapping.ts +++ b/frontend/src/core/utils/urlMapping.ts @@ -28,6 +28,8 @@ export const URL_TO_TOOL_MAP: Record = { '/pdf-to-pdfa': 'convert', '/pdf-to-word': 'convert', '/pdf-to-xml': 'convert', + '/cbz-to-pdf': 'convert', + '/pdf-to-cbz': 'convert', // Security tools '/add-password': 'addPassword', From eb5f36aa156738e31fc495b20b90d3e7100ef83d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 12 Nov 2025 15:47:37 +0000 Subject: [PATCH 11/16] Fix issues with opening files in desktop app (#4876) # Description of Changes Locking to just having one instance of the app running unifies the experience across all OSs. Opening new files in Stirling will cause the files to be opened in the existing window rather than spawning a new instance of the app with just that file in the new instance. There's much more to explore here to allow multiple windows open at once, but that can be done all from one instance of the app, and will likely make it easier to allow movement of files etc. across different windows. Also fixes extra newlines in the logs and directly builds to `.app` on Mac because it's frustrating during development to have to repeatedly mount & unmount the `.dmg`. --- frontend/src-tauri/Cargo.lock | 420 ++++++++++++++++++ frontend/src-tauri/Cargo.toml | 1 + frontend/src-tauri/src/commands/backend.rs | 4 + frontend/src-tauri/src/commands/files.rs | 66 ++- frontend/src-tauri/src/commands/mod.rs | 4 +- frontend/src-tauri/src/lib.rs | 40 +- frontend/src-tauri/tauri.conf.json | 2 +- .../src/desktop/hooks/useAppInitialization.ts | 85 +++- frontend/src/desktop/hooks/useOpenedFile.ts | 30 +- .../src/desktop/services/fileOpenService.ts | 50 +-- 10 files changed, 591 insertions(+), 111 deletions(-) diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 84cd44f029..d2abe9651d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -81,6 +81,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "atk" version = "0.18.2" @@ -182,6 +313,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.5.7" @@ -424,6 +568,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -774,6 +927,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -811,6 +991,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -966,6 +1167,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1352,6 +1566,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2079,6 +2299,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2463,6 +2696,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2498,6 +2741,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2679,6 +2928,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2711,6 +2971,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -3657,6 +3931,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stirling-pdf" version = "0.1.0" @@ -3670,6 +3950,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tokio", ] @@ -4056,6 +4337,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.9.1" @@ -4463,9 +4759,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tracing-core" version = "0.1.34" @@ -4515,6 +4823,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5530,6 +5849,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -5603,3 +5983,43 @@ dependencies = [ "quote", "syn 2.0.108", ] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", + "winnow 0.7.13", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 0ba9b7886f..e14bbaaeeb 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,5 +28,6 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" +tauri-plugin-single-instance = "2.0.1" tokio = { version = "1.0", features = ["time"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c465f7cc84..c7bce50f7d 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -234,6 +234,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("📤 Backend: {}", output_str)); // Look for startup indicators @@ -250,6 +252,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("📥 Backend Error: {}", output_str)); // Look for error indicators diff --git a/frontend/src-tauri/src/commands/files.rs b/frontend/src-tauri/src/commands/files.rs index 4b1cd2ad7c..2d22ac53ea 100644 --- a/frontend/src-tauri/src/commands/files.rs +++ b/frontend/src-tauri/src/commands/files.rs @@ -1,49 +1,47 @@ use crate::utils::add_log; use std::sync::Mutex; -// Store the opened file path globally -static OPENED_FILE: Mutex> = Mutex::new(None); +// Store the opened file paths globally (supports multiple files) +static OPENED_FILES: Mutex> = Mutex::new(Vec::new()); -// Set the opened file path (called by macOS file open events) -#[cfg(target_os = "macos")] -pub fn set_opened_file(file_path: String) { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = Some(file_path.clone()); - add_log(format!("📂 File opened via file open event: {}", file_path)); +// Add an opened file path +pub fn add_opened_file(file_path: String) { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.push(file_path.clone()); + add_log(format!("📂 File stored for later retrieval: {}", file_path)); } -// Command to get opened file path (if app was launched with a file) +// Command to get opened file paths (if app was launched with files) #[tauri::command] -pub async fn get_opened_file() -> Result, String> { - // First check if we have a file from macOS file open events - { - let opened_file = OPENED_FILE.lock().unwrap(); - if let Some(ref file_path) = *opened_file { - add_log(format!("📂 Returning stored opened file: {}", file_path)); - return Ok(Some(file_path.clone())); - } - } - - // Fallback to command line arguments (Windows/Linux) +pub async fn get_opened_files() -> Result, String> { + let mut all_files: Vec = Vec::new(); + + // Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour) let args: Vec = std::env::args().collect(); - - // Look for a PDF file argument (skip the first arg which is the executable) - for arg in args.iter().skip(1) { - if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() { - add_log(format!("📂 PDF file opened via command line: {}", arg)); - return Ok(Some(arg.clone())); - } + let pdf_files: Vec = args.iter() + .skip(1) + .filter(|arg| std::path::Path::new(arg).exists()) + .cloned() + .collect(); + + all_files.extend(pdf_files); + + // Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files) + { + let opened_files = OPENED_FILES.lock().unwrap(); + all_files.extend(opened_files.clone()); } - - Ok(None) + + add_log(format!("📂 Returning {} opened file(s)", all_files.len())); + Ok(all_files) } -// Command to clear the opened file (after processing) +// Command to clear the opened files (after processing) #[tauri::command] -pub async fn clear_opened_file() -> Result<(), String> { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = None; - add_log("📂 Cleared opened file".to_string()); +pub async fn clear_opened_files() -> Result<(), String> { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.clear(); + add_log("📂 Cleared opened files".to_string()); Ok(()) } diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 3f4ed7ecdc..f21bf80426 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -4,6 +4,4 @@ pub mod files; pub use backend::{start_backend, cleanup_backend}; pub use health::check_backend_health; -pub use files::{get_opened_file, clear_opened_file}; -#[cfg(target_os = "macos")] -pub use files::set_opened_file; +pub use files::{get_opened_files, clear_opened_files, add_opened_file}; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 9045e1e6d1..9a07845e10 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,13 +1,9 @@ -use tauri::{RunEvent, WindowEvent}; -#[cfg(target_os = "macos")] -use tauri::Emitter; +use tauri::{RunEvent, WindowEvent, Emitter, Manager}; mod utils; mod commands; -use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend}; -#[cfg(target_os = "macos")] -use commands::set_opened_file; +use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file}; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -15,12 +11,35 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + // This callback runs when a second instance tries to start + add_log(format!("📂 Second instance detected with args: {:?}", args)); + + // Scan args for PDF files (skip first arg which is the executable) + for arg in args.iter().skip(1) { + if std::path::Path::new(arg).exists() { + add_log(format!("📂 Forwarding file to existing instance: {}", arg)); + + // Store file for later retrieval (in case frontend isn't ready yet) + add_opened_file(arg.clone()); + + // Also emit event for immediate handling if frontend is ready + let _ = app.emit("file-opened", arg.clone()); + + // Bring the existing window to front + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } + } + } + })) .setup(|_app| { add_log("🚀 Tauri app setup started".to_string()); add_log("🔍 DEBUG: Setup completed".to_string()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs]) + .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { @@ -45,8 +64,9 @@ pub fn run() { let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); if file_path.ends_with(".pdf") { add_log(format!("📂 Processing opened PDF: {}", file_path)); - set_opened_file(file_path.to_string()); - let _ = app_handle.emit("macos://open-file", file_path.to_string()); + add_opened_file(file_path.to_string()); + // Use unified event name for consistency across platforms + let _ = app_handle.emit("file-opened", file_path.to_string()); } } } @@ -58,4 +78,4 @@ pub fn run() { } } }); -} \ No newline at end of file +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 45bb852dae..d845a2dc97 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "msi"], + "targets": ["deb", "rpm", "dmg", "app", "msi"], "icon": [ "icons/icon.png", "icons/icon.icns", diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index c64ea11808..a34547bcd0 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; @@ -17,31 +17,78 @@ export function useAppInitialization(): void { // Get file management actions const { addFiles } = useFileManagement(); - // Handle file opened with app (Tauri mode) - const { openedFilePath, loading: openedFileLoading } = useOpenedFile(); + // Handle files opened with app (Tauri mode) + const { openedFilePaths, loading: openedFileLoading } = useOpenedFile(); - // Load opened file and add directly to FileContext + // Track if we've already loaded the initial files to prevent duplicate loads + const initialFilesLoadedRef = useRef(false); + + // Load opened files and add directly to FileContext useEffect(() => { - if (openedFilePath && !openedFileLoading) { - const loadOpenedFile = async () => { - try { - const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath); - if (fileData) { - // Create a File object from the ArrayBuffer - const file = new File([fileData.arrayBuffer], fileData.fileName, { - type: 'application/pdf' - }); + if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) { + initialFilesLoadedRef.current = true; - // Add directly to FileContext - await addFiles([file]); - console.log('[Desktop] Opened file added to FileContext:', fileData.fileName); + const loadOpenedFiles = async () => { + try { + const filesArray: File[] = []; + + // Load all files in parallel + await Promise.all( + openedFilePaths.map(async (filePath) => { + try { + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (fileData) { + const file = new File([fileData.arrayBuffer], fileData.fileName, { + type: 'application/pdf' + }); + filesArray.push(file); + console.log('[Desktop] Loaded file:', fileData.fileName); + } + } catch (error) { + console.error('[Desktop] Failed to load file:', filePath, error); + } + }) + ); + + if (filesArray.length > 0) { + // Add all files to FileContext at once + await addFiles(filesArray); + console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); } } catch (error) { - console.error('[Desktop] Failed to load opened file:', error); + console.error('[Desktop] Failed to load opened files:', error); } }; - loadOpenedFile(); + loadOpenedFiles(); } - }, [openedFilePath, openedFileLoading, addFiles]); + }, [openedFilePaths, openedFileLoading, addFiles]); + + // Listen for runtime file-opened events (from second instances on Windows/Linux) + useEffect(() => { + const handleRuntimeFileOpen = async (filePath: string) => { + try { + console.log('[Desktop] Runtime file-opened event received:', filePath); + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (fileData) { + // Create a File object from the ArrayBuffer + const file = new File([fileData.arrayBuffer], fileData.fileName, { + type: 'application/pdf' + }); + + // Add directly to FileContext + await addFiles([file]); + console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName); + } + } catch (error) { + console.error('[Desktop] Failed to load runtime opened file:', error); + } + }; + + // Set up event listener and get cleanup function + const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen); + + // Clean up listener on unmount + return unlisten; + }, [addFiles]); } diff --git a/frontend/src/desktop/hooks/useOpenedFile.ts b/frontend/src/desktop/hooks/useOpenedFile.ts index 17d6a7870f..48565010fe 100644 --- a/frontend/src/desktop/hooks/useOpenedFile.ts +++ b/frontend/src/desktop/hooks/useOpenedFile.ts @@ -2,28 +2,28 @@ import { useState, useEffect } from 'react'; import { fileOpenService } from '@app/services/fileOpenService'; export function useOpenedFile() { - const [openedFilePath, setOpenedFilePath] = useState(null); + const [openedFilePaths, setOpenedFilePaths] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { const checkForOpenedFile = async () => { - console.log('🔍 Checking for opened file...'); + console.log('🔍 Checking for opened file(s)...'); try { - const filePath = await fileOpenService.getOpenedFile(); - console.log('🔍 fileOpenService.getOpenedFile() returned:', filePath); - - if (filePath) { - console.log('✅ App opened with file:', filePath); - setOpenedFilePath(filePath); - - // Clear the file from service state after consuming it - await fileOpenService.clearOpenedFile(); + const filePaths = await fileOpenService.getOpenedFiles(); + console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths); + + if (filePaths.length > 0) { + console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths); + setOpenedFilePaths(filePaths); + + // Clear the files from service state after consuming them + await fileOpenService.clearOpenedFiles(); } else { - console.log('ℹ️ No file was opened with the app'); + console.log('ℹ️ No files were opened with the app'); } } catch (error) { - console.error('❌ Failed to check for opened file:', error); + console.error('❌ Failed to check for opened files:', error); } finally { setLoading(false); } @@ -34,7 +34,7 @@ export function useOpenedFile() { // Listen for runtime file open events (abstracted through service) const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => { console.log('📂 Runtime file open event:', filePath); - setOpenedFilePath(filePath); + setOpenedFilePaths(prev => [...prev, filePath]); }); // Cleanup function @@ -43,5 +43,5 @@ export function useOpenedFile() { }; }, []); - return { openedFilePath, loading }; + return { openedFilePaths, loading }; } diff --git a/frontend/src/desktop/services/fileOpenService.ts b/frontend/src/desktop/services/fileOpenService.ts index 524776d326..67813df38d 100644 --- a/frontend/src/desktop/services/fileOpenService.ts +++ b/frontend/src/desktop/services/fileOpenService.ts @@ -1,22 +1,22 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; export interface FileOpenService { - getOpenedFile(): Promise; + getOpenedFiles(): Promise; readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>; - clearOpenedFile(): Promise; + clearOpenedFiles(): Promise; onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function } class TauriFileOpenService implements FileOpenService { - async getOpenedFile(): Promise { + async getOpenedFiles(): Promise { try { - console.log('🔍 Calling invoke(get_opened_file)...'); - const result = await invoke('get_opened_file'); - console.log('🔍 invoke(get_opened_file) returned:', result); + console.log('🔍 Calling invoke(get_opened_files)...'); + const result = await invoke('get_opened_files'); + console.log('🔍 invoke(get_opened_files) returned:', result); return result; } catch (error) { - console.error('❌ Failed to get opened file:', error); - return null; + console.error('❌ Failed to get opened files:', error); + return []; } } @@ -37,13 +37,13 @@ class TauriFileOpenService implements FileOpenService { } } - async clearOpenedFile(): Promise { + async clearOpenedFiles(): Promise { try { - console.log('🔍 Calling invoke(clear_opened_file)...'); - await invoke('clear_opened_file'); - console.log('✅ Successfully cleared opened file'); + console.log('🔍 Calling invoke(clear_opened_files)...'); + await invoke('clear_opened_files'); + console.log('✅ Successfully cleared opened files'); } catch (error) { - console.error('❌ Failed to clear opened file:', error); + console.error('❌ Failed to clear opened files:', error); } } @@ -67,15 +67,9 @@ class TauriFileOpenService implements FileOpenService { return; } - // Listen for macOS native file open events - const unlistenMacOS = await listen('macos://open-file', (event) => { - console.log('📂 macOS native file open event:', event.payload); - callback(event.payload as string); - }); - - // Listen for fallback file open events - const unlistenFallback = await listen('file-opened', (event) => { - console.log('📂 Fallback file open event:', event.payload); + // Listen for unified file open events (all platforms) + const unlisten = await listen('file-opened', (event) => { + console.log('📂 File open event received:', event.payload); callback(event.payload as string); }); @@ -83,8 +77,7 @@ class TauriFileOpenService implements FileOpenService { if (!isCleanedUp) { cleanup = () => { try { - unlistenMacOS(); - unlistenFallback(); + unlisten(); console.log('✅ File event listeners cleaned up'); } catch (error) { console.error('❌ Error during file event cleanup:', error); @@ -93,8 +86,7 @@ class TauriFileOpenService implements FileOpenService { } else { // Clean up immediately if cleanup was called during setup try { - unlistenMacOS(); - unlistenFallback(); + unlisten(); } catch (error) { console.error('❌ Error during immediate cleanup:', error); } @@ -118,9 +110,9 @@ class TauriFileOpenService implements FileOpenService { } class WebFileOpenService implements FileOpenService { - async getOpenedFile(): Promise { + async getOpenedFiles(): Promise { // In web mode, there's no file association support - return null; + return []; } async readFileAsArrayBuffer(_filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> { @@ -128,7 +120,7 @@ class WebFileOpenService implements FileOpenService { return null; } - async clearOpenedFile(): Promise { + async clearOpenedFiles(): Promise { // In web mode, no file clearing needed } From 3ae2946d57a15872ebce550419fdba28882b104e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:59:11 +0100 Subject: [PATCH 12/16] [V2] refactor(ui): migrate compress/booklet/sign-up to Mantine Checkbox (#4886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Updated `BookletImpositionSettings` to use Mantine's `Checkbox` for better UI consistency - Replaced manual checkbox implementations with Mantine's `Checkbox` in `CompressSettings` - Added labels and tooltips for enhanced accessibility and usability ### Before: image image image image ### After: image image image image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../BookletImpositionSettings.tsx | 147 +++++++++--------- .../tools/compress/CompressSettings.tsx | 20 +-- .../proprietary/routes/signup/SignupForm.tsx | 20 +-- 3 files changed, 90 insertions(+), 97 deletions(-) diff --git a/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index f0c76f04c2..d9c6682295 100644 --- a/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core"; +import { Stack, Text, Divider, Collapse, Button, NumberInput, Checkbox } from "@mantine/core"; import { BookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; @@ -21,28 +21,27 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f {/* Double Sided */} - + { + const isDoubleSided = event.currentTarget.checked; + onParameterChange('doubleSided', isDoubleSided); + // Reset to BOTH when turning double-sided back on + if (isDoubleSided) { + onParameterChange('duplexPass', 'BOTH'); + } else { + // Default to FIRST pass when going to manual duplex + onParameterChange('duplexPass', 'FIRST'); + } + }} + disabled={disabled} + label={ +
+ {t('bookletImposition.doubleSided.label', 'Double-sided printing')} + {t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')} +
+ } + /> {/* Manual Duplex Pass Selection - only show when double-sided is OFF */} {!parameters.doubleSided && ( @@ -90,47 +89,44 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f {/* Right-to-Left Binding */} - + onParameterChange('spineLocation', event.currentTarget.checked ? 'RIGHT' : 'LEFT')} + disabled={disabled} + label={ +
+ {t('bookletImposition.rtlBinding.label', 'Right-to-left binding')} + {t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')} +
+ } + /> {/* Add Border Option */} - + onParameterChange('addBorder', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addBorder.label', 'Add borders around pages')} + {t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')} +
+ } + /> {/* Gutter Margin */} - + onParameterChange('addGutter', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addGutter.label', 'Add gutter margin')} + {t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')} +
+ } + /> {parameters.addGutter && ( {/* Flip on Short Edge */} - + /> {/* Paper Size Note */} diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx index 7a7c93d675..398e0b7b47 100644 --- a/frontend/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; +import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; @@ -123,18 +123,12 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C {/* Compression Options */} - + onParameterChange('grayscale', event.currentTarget.checked)} + disabled={disabled} + label={t("compress.grayscale.label", "Apply Grayscale for compression")} + />
); diff --git a/frontend/src/proprietary/routes/signup/SignupForm.tsx b/frontend/src/proprietary/routes/signup/SignupForm.tsx index 35caf77644..9e6ac9dc06 100644 --- a/frontend/src/proprietary/routes/signup/SignupForm.tsx +++ b/frontend/src/proprietary/routes/signup/SignupForm.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; import { useTranslation } from 'react-i18next'; +import { Checkbox } from '@mantine/core'; import { SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; interface SignupFormProps { @@ -133,19 +134,20 @@ export default function SignupForm({ {/* Terms - only show if showTerms is true */} {showTerms && (
- setAgree?.(e.target.checked)} + onChange={(e) => setAgree?.(e.currentTarget.checked)} className="auth-checkbox" + label={ + + {t("legal.iAgreeToThe", 'I agree to all of the')}{' '} + + {t('legal.terms', 'Terms and Conditions')} + + + } /> -
)} From d06391a9275007736cd444f0d857244741793e2b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 12:11:59 +0000 Subject: [PATCH 13/16] Redesign and enable macOS signing (#4883) # Description of Changes Re-enable macOS signing and tweak so it runs successfully through CI. Also changes the runner to use macOS 15 instead of 13, which was throwing a deprecation warning in GitHub. Note that the runner doesn't affect the minimum target, which I've still got set to 10.15 (no idea if it actually works on 10.15 but let's assume that if it builds it works until someone can test it) --- .github/workflows/tauri-build.yml | 147 ++++++++++++++++------------- frontend/src-tauri/tauri.conf.json | 6 ++ 2 files changed, 86 insertions(+), 67 deletions(-) diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index d56c462d05..077704d235 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -47,21 +47,19 @@ jobs: "windows") echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT ;; - # "macos") - # echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT - # ;; + "macos") + echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT + ;; "linux") echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; *) - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; esac else # For PR/push events, build all platforms - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT fi build: @@ -96,7 +94,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable - targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} @@ -183,80 +181,96 @@ jobs: working-directory: ./frontend run: npm install - # Disabled Mac builds - Import Apple Developer Certificate - # - name: Import Apple Developer Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # env: - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - # run: | - # echo "Importing Apple Developer Certificate..." - # echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 - # security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security default-keychain -s build.keychain - # security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security set-keychain-settings -t 3600 -u build.keychain - # security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - # security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - # security find-identity -v -p codesigning build.keychain - # - name: Verify Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "Verifying Apple Developer Certificate..." - # CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - # echo "Certificate Info: $CERT_INFO" - # CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - # echo "Certificate ID: $CERT_ID" - # echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - # echo "Certificate imported." + - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + echo "Importing Apple Developer Certificate..." + echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Import certificate + security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Clean up + rm certificate.p12 - # - name: Check DMG creation dependencies (macOS only) - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." - # echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" - # echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" - # echo "Available disk space: $(df -h /tmp | tail -1)" - # echo "macOS version: $(sw_vers -productVersion)" - # echo "Available tools:" - # ls -la /usr/bin/hd* || echo "No hd* tools found" + - name: Verify Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "Verifying Apple Developer Certificate..." + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application") + echo "Certificate Info: $CERT_INFO" + CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') + echo "Certificate ID: $CERT_ID" + echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV + echo "Certificate imported successfully." - - name: Build Tauri app + - name: Check DMG creation dependencies (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." + echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" + echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" + echo "Available disk space: $(df -h /tmp | tail -1)" + echo "macOS version: $(sw_vers -productVersion)" + echo "Available tools:" + ls -la /usr/bin/hd* || echo "No hd* tools found" + + - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} SIGN: 1 - CI: true + CI: true with: projectPath: ./frontend tauriScript: npx tauri args: ${{ matrix.args }} - + + - name: Verify notarization (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Verifying notarization status..." + cd ./frontend/src-tauri/target + DMG_FILE=$(find . -name "*.dmg" | head -1) + if [ -n "$DMG_FILE" ]; then + echo "Found DMG: $DMG_FILE" + echo "Checking notarization ticket..." + spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete" + stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached" + else + echo "⚠️ No DMG file found to verify" + fi + - name: Rename artifacts shell: bash run: | mkdir -p ./dist cd ./frontend/src-tauri/target - + # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; - # find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; + find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; else find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; @@ -273,7 +287,7 @@ jobs: shell: bash run: | cd ./frontend/src-tauri/target - + # Check for expected artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then echo "Checking for Windows artifacts..." @@ -282,14 +296,13 @@ jobs: echo "❌ No Windows executable found" exit 1 fi - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # echo "Checking for macOS artifacts..." - # find . -name "*.dmg" -o -name "*.app" | head -5 - # if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then - # echo "❌ No macOS artifacts found" - # exit 1 - # fi + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + echo "Checking for macOS artifacts..." + find . -name "*.dmg" -o -name "*.app" | head -5 + if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then + echo "❌ No macOS artifacts found" + exit 1 + fi else echo "Checking for Linux artifacts..." find . -name "*.deb" -o -name "*.AppImage" | head -5 @@ -298,7 +311,7 @@ jobs: exit 1 fi fi - + echo "✅ Build artifacts found for ${{ matrix.name }}" - name: Test artifact sizes diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index d845a2dc97..a6dffc8812 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -50,6 +50,12 @@ "deb": { "desktopTemplate": "stirling-pdf.desktop" } + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null } }, "plugins": { From aa20dbb7a6e4dbab7e7b963c994252431e6359f4 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:53:57 +0000 Subject: [PATCH 14/16] Feature/v2/selected pageeditor rework (#4756) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/package-lock.json | 58 + frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 6 +- frontend/src/core/components/AppProviders.tsx | 21 +- .../components/layout/Workbench.module.css | 10 +- .../src/core/components/layout/Workbench.tsx | 7 +- .../pageEditor/DragDropGrid.module.css | 72 ++ .../components/pageEditor/DragDropGrid.tsx | 804 +++++++++++-- .../pageEditor/PageEditor.module.css | 42 +- .../core/components/pageEditor/PageEditor.tsx | 1003 ++++++----------- .../pageEditor/PageEditorControls.tsx | 14 +- .../components/pageEditor/PageThumbnail.tsx | 180 +-- .../pageEditor/commands/pageCommands.ts | 55 +- .../core/components/pageEditor/fileColors.ts | 61 + .../hooks/useEditedDocumentState.ts | 249 ++++ .../pageEditor/hooks/useEditorCommands.ts | 420 +++++++ .../pageEditor/hooks/useFileColorMap.ts | 33 + .../hooks/useInitialPageDocument.ts | 22 + .../pageEditor/hooks/usePageDocument.ts | 117 +- .../hooks/usePageEditorDropdownState.ts | 65 ++ .../pageEditor/hooks/usePageEditorExport.ts | 313 +++++ .../pageEditor/hooks/usePageEditorState.ts | 13 +- .../hooks/usePageSelectionManager.ts | 137 +++ .../pageEditor/hooks/useUndoManagerState.ts | 62 + .../pageEditor/pageEditorRightRailButtons.tsx | 16 + .../core/components/shared/AppConfigModal.tsx | 2 - .../shared/HoverActionMenu.module.css | 1 - .../components/shared/HoverActionMenu.tsx | 5 +- .../shared/PageEditorFileDropdown.tsx | 234 ++++ .../src/core/components/shared/Tooltip.tsx | 1 + .../core/components/shared/TopControls.tsx | 123 +- .../shared/pageEditor/useFileItemDragDrop.ts | 154 +++ .../components/tools/compare/compareView.css | 1 + .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 2 - .../tooltips/useSplitSettingsTips.ts | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 39 +- .../src/core/contexts/PageEditorContext.tsx | 359 ++++++ .../src/core/contexts/file/FileReducer.ts | 2 + frontend/src/core/hooks/useWheelZoom.ts | 83 ++ frontend/src/core/styles/zIndex.ts | 6 +- frontend/src/core/tools/Split.tsx | 5 +- frontend/src/core/types/fileContext.ts | 13 +- frontend/src/core/types/pageEditor.ts | 10 + 43 files changed, 3833 insertions(+), 990 deletions(-) create mode 100644 frontend/src/core/components/pageEditor/DragDropGrid.module.css create mode 100644 frontend/src/core/components/pageEditor/fileColors.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useFileColorMap.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts create mode 100644 frontend/src/core/components/shared/PageEditorFileDropdown.tsx create mode 100644 frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts create mode 100644 frontend/src/core/contexts/PageEditorContext.tsx create mode 100644 frontend/src/core/hooks/useWheelZoom.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index deb9b1a058..407f8ca066 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", @@ -505,6 +506,63 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@embedpdf/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0989a0dff..3fa614f359 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index aae5bbc9ed..2665b34200 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1011,7 +1011,8 @@ "title": "Choose Your Split Method" } }, - "selectMethod": "Select a split method" + "selectMethod": "Select a split method", + "resultsTitle": "Split Results" }, "rotate": { "title": "Rotate PDF", @@ -3605,7 +3606,8 @@ "toggleAnnotations": "Toggle Annotations Visibility", "annotationMode": "Toggle Annotation Mode", "draw": "Draw", - "save": "Save" + "save": "Save", + "saveChanges": "Save Changes" }, "search": { "title": "Search PDF", diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 3bf96f29f3..24f7931884 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext"; import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; +import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -64,15 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/frontend/src/core/components/layout/Workbench.module.css b/frontend/src/core/components/layout/Workbench.module.css index 602110e4bd..73d2e402f4 100644 --- a/frontend/src/core/components/layout/Workbench.module.css +++ b/frontend/src/core/components/layout/Workbench.module.css @@ -1,21 +1,21 @@ -.workbench-scrollable { +.workbenchScrollable { overflow-y: auto !important; overflow-x: hidden !important; } -.workbench-scrollable::-webkit-scrollbar { +.workbenchScrollable::-webkit-scrollbar { width: 0.375rem; } -.workbench-scrollable::-webkit-scrollbar-track { +.workbenchScrollable::-webkit-scrollbar-track { background: transparent; } -.workbench-scrollable::-webkit-scrollbar-thumb { +.workbenchScrollable::-webkit-scrollbar-thumb { background-color: var(--mantine-color-gray-4); border-radius: 0.1875rem; } -.workbench-scrollable::-webkit-scrollbar-thumb:hover { +.workbenchScrollable::-webkit-scrollbar-thumb:hover { background-color: var(--mantine-color-gray-5); } diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 4b3d1ebc80..f6477c67aa 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Box } from '@mantine/core'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; @@ -187,18 +186,16 @@ export default function Workbench() { {/* Main content area */} 0 ? '3.5rem' : '0'), - overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined, }} > {renderMainContent()}