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.
### Individual endpoints:
### 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