diff --git a/Collaborative Editing/Client/index.html b/Collaborative Editing/Client/index.html new file mode 100644 index 0000000..08e66b3 --- /dev/null +++ b/Collaborative Editing/Client/index.html @@ -0,0 +1,72 @@ + + + + + Syncfusion PDF Viewer - Collaborative Editing + + + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/Collaborative Editing/Client/index.js b/Collaborative Editing/Client/index.js new file mode 100644 index 0000000..621fc45 --- /dev/null +++ b/Collaborative Editing/Client/index.js @@ -0,0 +1,457 @@ +// ============================================================ +// Collaboration Configuration +// ============================================================ + +const userList = ['RIO', 'JOHN', 'MAXY', 'SHAI', 'SRI']; +const currentUserName = userList[Math.floor(Math.random() * userList.length)]; +const SERVICE_URL = 'http://localhost:8081/'; + +// ============================================================ +// State Management +// ============================================================ + +let appState = { + isDocumentLoaded: false, + collaborationStatus: 'initializing', // initializing | loading | connected | error + currentUser: currentUserName, + connectedUsers: [currentUserName], + roomName: '', + pdfViewer: null, + adapter: null, + client: null, + roomNameRef: '' +}; + +// ============================================================ +// Helper Functions - PDF Fetch and Load +// ============================================================ + +/** + * Loads a PDF blob into the viewer + */ +async function loadPDFBlobIntoViewer(pdfBlob) { + try { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + try { + const arrayBuffer = reader.result; + const uint8Array = new Uint8Array(arrayBuffer); + + // Load using viewer.load() with Uint8Array + if (appState.pdfViewer && typeof appState.pdfViewer.load === 'function') { + appState.pdfViewer.load(uint8Array, ''); + console.log('[App] Loaded PDF using viewer.load(Uint8Array)'); + resolve(); + return; + } + + // Fallback: Try loading via data URL + const dataReader = new FileReader(); + dataReader.onload = () => { + try { + const dataUrl = dataReader.result; + + if (appState.pdfViewer && typeof appState.pdfViewer.load === 'function') { + appState.pdfViewer.load(dataUrl, ''); + console.log('[App] Loaded PDF using viewer.load(dataUrl)'); + resolve(); + } else { + console.error('[App] Viewer does not support load method'); + reject(new Error('Viewer load method not available')); + } + } catch (error) { + reject(error); + } + }; + + dataReader.onerror = () => { + reject(new Error('Failed to read blob as data URL')); + }; + + dataReader.readAsDataURL(pdfBlob); + + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => { + reject(new Error('Failed to read blob as array buffer')); + }; + + reader.readAsArrayBuffer(pdfBlob); + }); + + } catch (error) { + console.error('[App] Error loading PDF blob:', error); + throw error; + } +} + +/** + * Fetches the current collaborative document from the server and loads it into the viewer + */ +async function fetchAndLoadPDFDocument() { + try { + console.log(`[App] Fetching PDF from room: ${appState.roomNameRef}`); + + const queryParams = new URLSearchParams({ + roomName: appState.roomNameRef || 'default' + }); + + const response = await fetch( + `${SERVICE_URL}api/CollaborativeEditing/GetPDFDocument?${queryParams.toString()}`, + { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + } + ); + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(`Server error: ${result.error}`); + } + + console.log(`[App] PDF retrieved successfully - Size: ${result.contentLength} bytes`); + + // Decode Base64 content to binary string + const binaryString = atob(result.content); + + // Convert binary string to Uint8Array + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create Blob from Uint8Array + const pdfBlob = new Blob([bytes], { type: 'application/pdf' }); + console.log(`[App] Converted to Blob - Size: ${pdfBlob.size} bytes`); + + // Load the PDF into the viewer + await loadPDFBlobIntoViewer(pdfBlob); + console.log('[App] PDF loaded into viewer'); + + } catch (error) { + console.error('[App] Error fetching PDF document:', error); + throw error; + } +} + +// ============================================================ +// UI Update Functions +// ============================================================ + +/** + * Updates the status bar with current collaboration status + */ +function updateStatusBar() { + const statusBar = document.getElementById('collaborationStatusBar'); + if (statusBar) { + const statusColor = + appState.collaborationStatus === 'connected' ? '#28a745' : + appState.collaborationStatus === 'error' ? '#dc3545' : '#ffc107'; + + statusBar.innerHTML = ` +
+
+ User: ${appState.currentUser} | + Status: ${appState.collaborationStatus} | + Room: ${appState.roomName || 'N/A'} +
+
+ Connected Users: ${appState.connectedUsers.join(', ') || 'None'} +
+
+ `; + } +} + +// ============================================================ +// Event Handlers - Collaboration Lifecycle +// ============================================================ + +/** + * Handler for viewer.resourcesLoaded event + * + * This event fires when the PdfViewer has initialized all resources. + * We use it to: + * 1. Initialize the collaboration adapter and client + * 2. Load the document from the collaboration service + * 3. Join the collaboration room + * 4. Fetch and load the current PDF state + */ +async function handleResourcesLoaded() { + console.log('[App] Viewer resourcesLoaded event triggered'); + + if (!appState.isDocumentLoaded) { + try { + console.log(`[App] Initializing collaboration - User: ${appState.currentUser}, Service: ${SERVICE_URL}`); + + appState.collaborationStatus = 'loading'; + updateStatusBar(); + appState.isDocumentLoaded = true; + + // Initialize collaboration asynchronously + (async () => { + try { + // Step 1: Initialize PdfViewerAdapter + const adapter = new PdfViewerAdapter(appState.pdfViewer, SERVICE_URL, appState.currentUser); + appState.adapter = adapter; + console.log('[App] PdfViewerAdapter initialized'); + + // Step 2: Create and configure CollaborationClient + const client = new ej.collaborator.CollaborationClient(adapter, { + serviceUrl: SERVICE_URL, + connectionType: 'websocket', + currentUser: appState.currentUser, + onRemoteAction: (action, data) => { + console.log('[App] Applying remote action:', action); + if (adapter && typeof adapter.applyRemoteAction === 'function') { + adapter.applyRemoteAction(action, data); + } + }, + onUserJoined: (user) => { + console.log('[App] User joined collaboration:', user); + const userName = user.userName || user.currentUser; + if (!appState.connectedUsers.includes(userName)) { + appState.connectedUsers.push(userName); + updateStatusBar(); + } + }, + onUserLeft: (user) => { + console.log('[App] User left collaboration:', user); + const userName = user.userName || user.currentUser; + appState.connectedUsers = appState.connectedUsers.filter(u => u !== userName); + updateStatusBar(); + } + }); + appState.client = client; + + console.log('[App] CollaborationClient initialized'); + + // Step 3: Load from server (gets room name and pending operations) + const roomName = await adapter.loadFromServer(); + appState.roomNameRef = roomName; + appState.roomName = roomName; + updateStatusBar(); + console.log(`[App] Loaded from server - Room: ${roomName}`); + + // Step 4: Join the collaboration room with the client + await client.joinRoomAsync(roomName); + console.log(`[App] Joined collaboration room: ${roomName}`); + + // Step 5: Fetch and load the current PDF document state + await fetchAndLoadPDFDocument(); + console.log('[App] PDF document loaded successfully'); + + appState.collaborationStatus = 'connected'; + updateStatusBar(); + + } catch (error) { + console.error('[App] Error during collaboration initialization:', error); + appState.collaborationStatus = 'error'; + updateStatusBar(); + // Fallback: Load default document + console.log('[App] Falling back to default document'); + } + })(); + + } catch (error) { + console.error('[App] Error initializing collaboration:', error); + appState.collaborationStatus = 'error'; + updateStatusBar(); + } + } +} + +/** + * Handler for viewer.documentChanged event + * + * This event fires when the user makes changes to: + * - Annotations (add, modify, delete) + * - Form fields (add, modify, delete) + * - Page organizer (reorder, insert, delete pages) + * + * We package these changes as operations and send them to the server + * for broadcast to other collaborators. + */ +function handleDocumentChanged(args) { + try { + // Handle AnnotationChangedEventArgs + if (args && 'annotationId' in args) { + console.log('[App] Annotation changed:', args.annotationId); + + let operations = []; + if (args.action) { + operations = [{ + action: args.action, + annotation: args.annotationId, + type: 'annotation', + isRedacted: args.isRedacted + }]; + } else { + operations = [{ + type: 'removeUser', + currentUser: appState.currentUser + }]; + } + + console.log('[App] Annotation operation:', operations); + if (appState.adapter && appState.adapter.sendActionToServer) { + appState.adapter.sendActionToServer(operations).catch(err => + console.error('[App] Error sending annotation operation:', err) + ); + } + } + // Handle FormFieldChangedEventArgs + else if (args && 'formField' in args && !('fieldName' in args)) { + console.log('[App] Form field changed:', args.formField); + + const operations = [{ + action: args.action, + formField: args.formField, + type: 'formField' + }]; + + console.log('[App] Form field operation:', operations); + if (appState.adapter && appState.adapter.sendActionToServer) { + appState.adapter.sendActionToServer(operations).catch(err => + console.error('[App] Error sending form field operation:', err) + ); + } + } + // Handle FormFieldFocusOutEventArgs (form field value updates) + else if (args && 'fieldName' in args) { + console.log('[App] Form field updated:', args.fieldName); + + const operations = [{ + action: 'formFieldUpdate', + data: args, + type: 'formField' + }]; + + console.log('[App] Form field update operation:', operations); + if (appState.adapter && appState.adapter.sendActionToServer) { + appState.adapter.sendActionToServer(operations).catch(err => + console.error('[App] Error sending form field update:', err) + ); + } + } + // Handle PageOrganizerSavedEventArgs + else if (args && 'organizePageActions' in args) { + console.log('[App] Page organizer changed'); + + const eventData = args; + const actionDetails = args.organizePageActions && typeof args.organizePageActions === 'string' + ? JSON.parse(args.organizePageActions) + : ""; + + let operations = []; + + if (eventData && eventData.savedDocument === null && actionDetails.action && actionDetails.action === 'applyCancelled') { + // User cancelled the page organizer operation + operations = [{ + type: 'removeUser', + currentUser: appState.currentUser + }]; + console.log('[App] Page organizer operation cancelled'); + } + else if (eventData && eventData.savedDocument !== null && actionDetails.length > 0 && actionDetails[0].action !== 'applyCancelled') { + // Page organizer operation applied successfully + operations = [{ + action: 'pageOrganizerUpdate', + data: args.organizePageActions, + type: 'pageOrganizer' + }]; + console.log('[App] Page organizer operation:', operations); + } else { + console.log('[App] No valid page organizer operation to send'); + return; + } + + if (appState.adapter && appState.adapter.sendActionToServer) { + appState.adapter.sendActionToServer(operations).catch(err => + console.error('[App] Error sending page organizer operation:', err) + ); + } + } + + } catch (error) { + console.error('[App] Error processing document change:', error); + } +} + +// ============================================================ +// Initialization +// ============================================================ + +document.addEventListener('DOMContentLoaded', function () { + // Create collaboration status bar + const containerDiv = document.getElementById('container'); + if (containerDiv) { + // Create status bar + const statusBar = document.createElement('div'); + statusBar.id = 'collaborationStatusBar'; + statusBar.style.cssText = ` + padding: 10px 15px; + background-color: #f0f0f0; + border-bottom: 1px solid #ddd; + font-size: 12px; + `; + containerDiv.insertBefore(statusBar, containerDiv.firstChild); + + // Create PDF Viewer container + const pdfViewerDiv = document.createElement('div'); + pdfViewerDiv.id = 'PdfViewer'; + pdfViewerDiv.style.cssText = 'height: calc(100% - 51px); width: 100%;'; + containerDiv.appendChild(pdfViewerDiv); + } + + // Initialize PDF Viewer + appState.pdfViewer = new ej.pdfviewer.PdfViewer({ + enableCollaborativeEditing: true, + resourceUrl: 'https://cdn.syncfusion.com/ej2/34.1.29/dist/ej2-pdfviewer-lib', + resourcesLoaded: handleResourcesLoaded, + documentChanged: handleDocumentChanged + }); + + // Inject required services + ej.pdfviewer.PdfViewer.Inject( + ej.pdfviewer.TextSelection, + ej.pdfviewer.TextSearch, + ej.pdfviewer.Print, + ej.pdfviewer.Navigation, + ej.pdfviewer.Toolbar, + ej.pdfviewer.Magnification, + ej.pdfviewer.Annotation, + ej.pdfviewer.FormDesigner, + ej.pdfviewer.FormFields, + ej.pdfviewer.LinkAnnotation, + ej.pdfviewer.BookmarkView, + ej.pdfviewer.ThumbnailView, + ej.pdfviewer.PageOrganizer + ); + + // Append to DOM + appState.pdfViewer.appendTo('#PdfViewer'); + + // Update status bar + updateStatusBar(); + + // Cleanup on page unload + window.addEventListener('beforeunload', function () { + console.log('[App] Cleaning up collaboration resources'); + if (appState.client) { + // Disconnect gracefully if needed + } + }); +}); \ No newline at end of file diff --git a/Collaborative Editing/Client/pdfViewerAdapter.js b/Collaborative Editing/Client/pdfViewerAdapter.js new file mode 100644 index 0000000..ac15db9 --- /dev/null +++ b/Collaborative Editing/Client/pdfViewerAdapter.js @@ -0,0 +1,320 @@ +/** + * PdfViewerAdapter - Implements ICollaborationProvider for Syncfusion PDF Viewer + * + * This adapter acts as a bridge between the PDF Viewer and the CollaborationClient. + * It manages: + * - Document loading and state initialization + * - Collaborative editing operations + * - Server communication for real-time synchronization + */ + +class PdfViewerAdapter { + /** + * Constructor + * @param {Object} viewer - The PdfViewer instance + * @param {string} serviceUrl - The base URL for collaboration service + * @param {string} currentUser - The current user name + */ + constructor(viewer, serviceUrl, currentUser) { + this.viewer = viewer; + this.serviceUrl = serviceUrl; + this.currentUser = currentUser; + this.fileName = ''; + this.currentRoomName = ''; + this.isDocumentLoaded = false; + this.pendingOperations = []; + + // Initialize the collaborative editing handler + this.collaborativeEditingHandler = new ej.pdfviewer.CollaborativeEditingHandler( + viewer, + currentUser + ); + + console.log('[PdfViewerAdapter] Initialized for user:', currentUser); + } + + /** + * Extracts or generates room ID from URL query parameters. + * Ensures a unique room ID is set for the collaboration session. + * Updates the browser URL history with the generated room ID. + * + * @returns {string} Room identifier + */ + getRoomName() { + // Check for browser environment + if (typeof window !== 'undefined') { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + let roomId = urlParams.get('id'); + + if (!roomId) { + roomId = Math.random().toString(32).slice(2); + window.history.replaceState({}, '', `?id=${roomId}`); + } + + return roomId; + } + + // Server-side environment or fallback + return Math.random().toString(32).slice(2); + } + + /** + * Fetches the document from the product's REST API and joins a collaboration room. + * Returns the room name to be used by collaboration client. + * + * Flow: + * 1. Generate or extract room name from URL + * 2. POST to ImportFile endpoint with roomName + * 3. Server returns all pending operations for state reconstruction + * 4. Initialize collaboration context with room info and version + * 5. Apply initial state snapshots (annotations, form fields, etc.) + * + * @param {string} fileName - Optional file name to load + * @returns {Promise} The room name + */ + async loadFromServer(fileName) { + this.isDocumentLoaded = false; + this.fileName = fileName || 'document.pdf'; + + const roomName = this.getRoomName(); + this.currentRoomName = roomName; + + try { + console.log('[PdfViewerAdapter] Loading from server - Room:', roomName); + + const response = await fetch( + `${this.serviceUrl}api/CollaborativeEditing/ImportFile`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + roomName: roomName, + fileName: this.fileName, + currentUser: this.currentUser + }) + } + ); + + if (!response.ok) { + throw new Error( + `Failed to join collaboration room: ${response.statusText}` + ); + } + + const responseText = await response.text(); + await this.open(responseText, roomName); + + return roomName; + } catch (error) { + console.error('[PdfViewerAdapter] Error loading from server:', error); + throw error; + } + } + + /** + * Initializes collaboration context and applies initial document state. + * + * Process: + * 1. Parse server response containing version and pending operations + * 2. Update handler with room info and version tracking + * 3. Apply initial state snapshots (annotations, form fields, page organizer) + * 4. Mark document as loaded for subsequent operations + * + * @param {string} responseText - JSON response text from ImportFile endpoint + * @param {string} roomName - Current collaboration room name + */ + async open(responseText, roomName) { + try { + const data = JSON.parse(responseText); + + // Extract version for document state + const version = data.version || data.currentVersion || 0; + + console.log('[PdfViewerAdapter] Opening document - Version:', version, '- Operations count:', data.operations ? data.operations.length : 0); + + // Update collaboration handler with room info and version tracking + this.collaborativeEditingHandler.updateRoomInfo( + roomName, + version, + `${this.serviceUrl}api/CollaborativeEditing/` + ); + + // Apply initial state: annotations, form fields, page organizer snapshots + // These are stored as snapshots, not incremental operations + this.pendingOperations = data.operations || []; + if (data.operations && data.operations.length > 0) { + console.log('[PdfViewerAdapter] Applying', data.operations.length, 'pending operations'); + for (const op of data.operations) { + try { + this.collaborativeEditingHandler.applyRemoteAction(op.type, op); + } catch (opError) { + console.warn('[PdfViewerAdapter] Error applying operation:', op, opError); + } + } + } + + this.isDocumentLoaded = true; + console.log('[PdfViewerAdapter] Document initialization complete'); + } catch (error) { + console.error('[PdfViewerAdapter] Error initializing document:', error); + throw error; + } + } + + /** + * Sends local changes to the collaboration service via UpdateAction endpoint. + * + * Flow: + * 1. Validate operations array + * 2. Delegate to handler for routing by operation type + * 3. Handler increments version and sends to UpdateAction endpoint + * 4. Server broadcasts operation to other clients (except sender) + * + * @param {Array} operations - Array of operations/changes from the local user + */ + async sendActionToServer(operations) { + try { + if (!operations || operations.length === 0) { + console.warn('[PdfViewerAdapter] No operations to send'); + return; + } + + console.log('[PdfViewerAdapter] Sending', operations.length, 'operations to server'); + + // Delegate to handler which manages routing and UpdateAction API calls + await this.collaborativeEditingHandler.sendActionToServer(operations); + } catch (error) { + console.error('[PdfViewerAdapter] Error sending operations:', error); + throw error; + } + } + + /** + * Applies remote changes received from other collaborators. + * Handles user profile images and delegates logic to the collaborative editing handler. + * + * @param {string} action - Type of action being applied (e.g., 'annotationUpdate', 'formFieldUpdate', 'addUser', 'removeUser') + * @param {Object} data - Object containing action data with ICollaborationActionData interface + */ + applyRemoteAction(action, data) { + try { + if (action === 'addUser') { + // Handle multiple users + if (data && data.payload && Array.isArray(data.payload) && data.payload.length > 0) { + data.payload.forEach((user) => { + this._assignUserImage(user); + }); + } + // Handle single user + else if (data && data.payload) { + this._assignUserImage(data.payload); + } + } + else if (action === 'connectionId') { + // Handle connection ID - assign image to current user + const image = this._getUserImage(this.currentUser); + if (data && typeof data.payload !== 'undefined') { + data.payload = { payload: data.payload, image: image }; + } + } + + // Delegate to handler for actual action application + if (this.collaborativeEditingHandler && this.collaborativeEditingHandler.applyRemoteAction) { + const payloadToApply = (data && data.payload) ? data.payload : data; + this.collaborativeEditingHandler.applyRemoteAction(action, payloadToApply); + } + } catch (error) { + console.error('[PdfViewerAdapter] Error applying remote action:', error); + } + } + + /** + * Helper: Assign user image based on username + * @private + * @param {Object} user - User object to assign image to + */ + _assignUserImage(user) { + if (user && user.currentUser) { + user.image = this._getUserImage(user.currentUser); + } + } + + /** + * Helper: Get avatar image URL for a user + * @private + * @param {string} userName - The username + * @returns {string} Avatar image URL + */ + _getUserImage(userName) { + const avatarMap = { + 'RIO': 'https://ej2.syncfusion.com/demos/src/avatar/images/pic01.png', + 'JOHN': 'https://ej2.syncfusion.com/demos/src/avatar/images/pic03.png', + 'MAXY': 'https://ej2.syncfusion.com/demos/src/avatar/images/pic02.png', + 'SHAI': 'https://ej2.syncfusion.com/demos/src/avatar/images/pic04.png', + 'SRI': 'https://ej2.syncfusion.com/demos/src/avatar/images/pic05.png' + }; + return avatarMap[userName] || 'https://ej2.syncfusion.com/demos/src/avatar/images/default.png'; + } + + /** + * Updates pending operations by applying them to the document. + * This is used to sync initial state from server. + */ + updatePendingOperations() { + try { + // Apply initial state: annotations, form fields, page organizer snapshots + // These are stored as snapshots, not incremental operations + if (this.pendingOperations && this.pendingOperations.length > 0) { + console.log('[PdfViewerAdapter] Updating', this.pendingOperations.length, 'pending operations'); + for (const op of this.pendingOperations) { + try { + this.collaborativeEditingHandler.applyRemoteAction(op.type, op); + } catch (opError) { + console.warn('[PdfViewerAdapter] Error applying pending operation:', op, opError); + } + } + } + } catch (error) { + console.error('[PdfViewerAdapter] Error updating pending operations:', error); + } + } + + /** + * Gets the current document state + * Used for snapshots and sync operations + * + * @returns {Object} Current document state + */ + getDocumentState() { + try { + return { + currentUser: this.currentUser, + roomName: this.currentRoomName, + isDocumentLoaded: this.isDocumentLoaded, + fileName: this.fileName + }; + } catch (error) { + console.error('[PdfViewerAdapter] Error getting document state:', error); + return null; + } + } + + /** + * Cleans up resources + */ + dispose() { + try { + console.log('[PdfViewerAdapter] Disposing resources'); + if (this.collaborativeEditingHandler) { + this.collaborativeEditingHandler = null; + } + this.viewer = null; + this.pendingOperations = []; + } catch (error) { + console.error('[PdfViewerAdapter] Error disposing:', error); + } + } +} diff --git a/Collaborative Editing/README.md b/Collaborative Editing/README.md new file mode 100644 index 0000000..b8b50a3 --- /dev/null +++ b/Collaborative Editing/README.md @@ -0,0 +1,110 @@ +# Collaborative Editing in Syncfusion JavaScript PDF Viewer + +This sample demonstrates real-time collaborative editing in the Syncfusion JavaScript (ES5) PDF Viewer. It uses the Syncfusion Collaborator client package with a Node.js Collaboration Server to synchronize PDF Viewer actions between users. + +Users can collaborate on the same PDF room and see shared changes to: + +- Annotations, such as comments, highlights, drawings, and stamps +- Form field interactions and value updates +- Page Organizer operations, such as page reordering and page changes + +All users who need to collaborate must open the client with the same room ID. + +## Prerequisites + +- Node.js 14 or later +- npm +- A Redis instance reachable by the Collaboration Server +- Two or more browser tabs or windows for testing collaboration + +## Project Structure + +```text +Collaborative Editing/ +├── Client/ # JavaScript (ES5) PDF Viewer application +└── Server/ # Node.js Collaboration Server and PDF storage APIs +``` + +## Run the Sample Locally + +Open two terminal windows from the `Collaborative Editing` folder. + +### 1. Start the Collaboration Server + +```bash +cd Server +npm install +npm start +``` + +The server listens on: + +```text +http://localhost:8081 +``` + +The client is configured to use this URL in `Client/index.js` through the `SERVICE_URL` constant. If the server URL changes, update the client configuration to use the same URL. + +### 2. Start the JavaScript Client + +In a second terminal: + +```bash +cd Client +npx serve . +``` + +The client will be served locally. The URL will be similar to: + +```text +http://localhost:3000 (or another available port) +``` + +Open that URL in a browser. + +## Test Collaborative Editing + +1. Open the client in the first browser tab. +2. Confirm that the PDF Viewer toolbar loads and the collaboration status bar displays user information. +3. Copy the room ID from the browser URL or from the collaboration status bar. The room ID is the `id` query parameter, for example: + + ```text + http://localhost:3000/?id=3tlfqgkp854 + ``` + +4. Open the client in another browser tab or window. +5. Replace its URL with the same room ID, for example: + + ```text + http://localhost:3000/?id=3tlfqgkp854 + ``` + +6. Confirm that both users show the same room ID in the collaboration status bar and are listed under "Connected Users". +7. Make changes from either session and verify that the other session receives them. Test annotations, form fields, and Page Organizer actions. + +If no `id` is provided, the client generates a new room ID. Share the generated URL with other collaborators so that everyone joins the same room. + +## Collaboration Notes + +- The room ID identifies the shared collaboration session. Users with different room IDs are placed in different sessions. +- Keep the Collaboration Server running while testing real-time updates. +- The Collaboration Server uses Redis to persist and distribute collaboration actions. Ensure its Redis configuration is valid before starting the server. +- For a hosted deployment, configure the client with the hosted Collaboration Server URL and use a shared, reachable Redis instance. All users must access the same client deployment and preserve the same `?id=` value to remain in one room. +- Do not use `localhost` in a hosted client configuration because it refers to each individual user's computer. + +## Useful Commands + +| Directory | Command | Description | +|---|---|---| +| `Server` | `npm install` | Install server dependencies | +| `Server` | `npm start` | Start the Collaboration Server on port 8081 | +| `Client` | `npx serve .` | Start the HTTP server to serve the client files | +| `Client` | `npx serve . --port 3000` | Start the HTTP server on a specific port | + +## Troubleshooting + +- **The client cannot connect:** Confirm that the server is running on port `8081` and that `SERVICE_URL` in `Client/index.js` matches the server URL. +- **PDF Viewer does not load:** Ensure that the Syncfusion JavaScript library files are properly loaded. Check the browser console for any script loading errors. +- **Users do not see each other's changes:** Confirm that every browser URL contains the same `id` value and that Redis is available to the server. +- **A new room appears unexpectedly:** Check that the URL includes `?id=`. Without it, the client generates a new room ID. +- **Cannot connect to collaboration server:** Verify that the WebSocket connection is established. Check network tab in browser developer tools to confirm WebSocket communication. diff --git a/Collaborative Editing/Server/README.md b/Collaborative Editing/Server/README.md new file mode 100644 index 0000000..79e3c24 --- /dev/null +++ b/Collaborative Editing/Server/README.md @@ -0,0 +1,666 @@ +# PDF Viewer Collaboration Server (Node.js) + +A robust Node.js server implementation for real-time PDF collaborative editing, featuring version-based state management, multi-user support, and Redis-backed action persistence. + +## 📋 Quick Start + +### Prerequisites +- **Node.js** v14+ (v16+ recommended) +- **npm** v6+ +- **Redis** (local or cloud-hosted) +- **@syncfusion/ej2-collaborator-server** package + +### Installation + +1. **Navigate to the project directory:** +```bash +cd NodeJSServer +``` + +2. **Install dependencies:** +```bash +npm install +``` + +3. **Configure Redis connection** in `server.js`: +```javascript +redis: { + host: 'your-redis-host', + port: 6380, + username: 'default', + password: 'your-redis-password', + tls: {} +} +``` + +> **For Azure Redis**: Use port `6380` with `tls: {}` enabled. + +4. **Start the server:** +```bash +npm start +``` + +### Expected Output +``` +***** NODE BUILD 2026-07-23 ***** +***** PDF Viewer Collaboration Server ***** +ActionService initialized: true +``` + +The server will start on `http://localhost:8080` + +--- + +## ⚠️ Important: Correct API Endpoint Format + +When making requests to this server, **use the exact endpoint paths with proper casing**: + +### ✅ **CORRECT** Endpoint Pattern +``` +http://localhost:8080/api/CollaborativeEditing/ImportFile +http://localhost:8080/api/CollaborativeEditing/UpdateAction +``` + +### ❌ **INCORRECT** (will not work) +``` +http://localhost:8080/api/collaboration/ImportFile ← lowercase won't work +http://localhost:8080/api/Collaboration/ImportFile ← wrong casing +http://localhost:8080/api/collaborative-editing/ImportFile ← hyphenated won't work +``` + +**Key Point**: The endpoint is **`CollaborativeEditing`** (capital C and capital E), not `collaboration` or `collaborative-editing`. + +--- + +## 🏗️ Project Architecture + +### File Structure +``` +NodeJSServer/ +├── adapters/ +│ └── PdfViewerCollaborationAdapter.js # Business logic & data transformation +├── controllers/ +│ └── collaborative-editing-controller.js # HTTP request handling & orchestration +├── server.js # Server initialization & setup +├── package.json # Dependencies & scripts +├── README.md # This file +├── IMPLEMENTATION_GUIDE.md # Detailed API documentation +├── MIGRATION_SUMMARY.md # .NET to Node.js migration details +├── CHANGELOG.md # Version history +└── DIFFERENCES.md # Document Editor vs PDF Viewer differences +``` + +### Architecture Pattern + +``` +┌─────────────────────────────────────────────────────────┐ +│ PDF Viewer Client │ +└────────────────┬──────────────────────────────────────────┘ + │ + ┌────────▼────────────────┐ + │ HTTP Endpoints: │ + │ - /ImportFile │ + │ - /UpdateAction │ + │ - /GetActionsFromServer │ + │ - /SaveRequest │ + └────────┬────────────────┘ + │ + ┌────────▼─────────────────────────────────────┐ + │ CollaborativeEditingController │ + │ (Request validation & orchestration) │ + │ │ + │ Routes: │ + │ - POST /ImportFile → Get snapshots │ + │ - POST /UpdateAction → Store & broadcast + │ - POST /GetActionsFromServer → Get history │ + │ - POST /SaveRequest → Cleanup Redis │ + └────────┬──────────────────────────────────────┘ + │ + ┌────────▼──────────────────────────────────────┐ + │ PdfViewerCollaborationAdapter │ + │ (Business logic & transformations) │ + │ │ + │ Methods: │ + │ - mapControlToGenericAction() │ + │ - mapGenericToControlAction() │ + │ - transformOperations() │ + │ - processSaveRequestAsync() │ + │ - notifySaveCompletion() │ + └────────┬──────────────────────────────────────┘ + │ + ┌────────▼───────────────────────────────┐ + │ CollaborationServer Infrastructure │ + │ (ActionService, Redis, Transport) │ + │ │ + │ - ActionService: CRUD for actions │ + │ - Redis: Persistent storage │ + │ - Transport: WebSocket communication │ + └────────┴───────────────────────────────┘ +``` + +--- + +## 🔄 Action Types & Request Format + +The server supports four collaborative action types, all following the unified `CollaborativeEditingRequest` format: + +### Base Request Structure +```javascript +{ + roomName: string, // Collaboration room identifier + connectionId: string, // Client connection ID + userName: string, // User performing the action + currentVersion: number, // Client-side version number + type: string, // Action type: annotation|formField|formFieldAction|pageOrganizer + data: object // Type-specific data +} +``` + +### 1. **Annotation** - XFDF-based Annotation Changes +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + userName: "Alice", + type: "annotation", + currentVersion: 5, + data: { + xfdfData: "..." // Required: Complete XFDF markup + } +} +``` + +**Use Cases:** +- Add/modify comments, highlights, underlines +- Handwritten annotations +- Drawing markups +- Stamp additions + +--- + +### 2. **FormField** - Single Form Field Value Update +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + userName: "Alice", + type: "formField", + currentVersion: 5, + data: { + jsonData: "{\"field1\": \"value1\", \"field2\": \"value2\"}" // Required: JSON string + } +} +``` + +**Use Cases:** +- Update individual form field values +- Standalone field modifications +- Quick value changes + +--- + +### 3. **FormFieldAction** - Form Field Creation/Update/Deletion +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + userName: "Alice", + type: "formFieldAction", + currentVersion: 5, + data: { + changes: "{\"add\": [...], \"update\": [...], \"delete\": [...]}" // Required: JSON string + } +} +``` + +**Use Cases:** +- Create new form fields +- Batch field operations +- Field property changes +- Field deletion + +--- + +### 4. **PageOrganizer** - Page Order, Rotation, Deletion +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + userName: "Alice", + type: "pageOrganizer", + currentVersion: 5, + data: { + pages: [0, 2, 1], // Reordered page indices + rotations: {"0": 90, "2": 180} // Page rotations in degrees + } +} +``` + +**Use Cases:** +- Reorder pages +- Rotate pages +- Delete pages +- Reorganize document structure + +--- + +## 📡 API Endpoints + +### 1. **POST /api/CollaborativeEditing/ImportFile** +Import/join a collaboration room and retrieve current state snapshots. + +**Correct URL:** +``` +http://localhost:8080/api/CollaborativeEditing/ImportFile +``` + +**Request:** +```javascript +{ + roomName: "room123", + fileName: "document.pdf" +} +``` + +**Curl Example:** +```bash +curl -X POST http://localhost:8080/api/CollaborativeEditing/ImportFile \ + -H "Content-Type: application/json" \ + -d '{ + "roomName": "room123", + "fileName": "document.pdf" + }' +``` + +**Response:** +```javascript +{ + status: "success", + roomName: "room123", + fileName: "document.pdf", + currentVersion: 5, + annotations: { + snapshot: "...", // Latest XFDF snapshot + version: 5 + }, + formFields: { + snapshot: "{...}", // Merged form field state + version: 5 + }, + pageOrganizer: { + snapshot: {...}, // Latest page organizer state + version: 5 + } +} +``` + +--- + +### 2. **POST /api/CollaborativeEditing/UpdateAction** +Handle all collaborative update actions (annotation, formField, formFieldAction, pageOrganizer). + +**Correct URL:** +``` +http://localhost:8080/api/CollaborativeEditing/UpdateAction +``` + +**Request:** +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + userName: "Alice", + type: "annotation", // or "formField", "formFieldAction", "pageOrganizer" + currentVersion: 5, + data: { + xfdfData: "..." // Type-specific data + } +} +``` + +**Curl Example:** +```bash +curl -X POST http://localhost:8080/api/CollaborativeEditing/UpdateAction \ + -H "Content-Type: application/json" \ + -d '{ + "roomName": "room123", + "connectionId": "conn-456", + "userName": "Alice", + "type": "annotation", + "currentVersion": 5, + "data": { + "xfdfData": "..." + } + }' +``` + +**Response:** +```javascript +{ + status: "success", + roomName: "room123", + version: 6, + message: "Action stored and broadcast" +} +``` + +**Error Response:** +```javascript +{ + status: "error", + message: "Invalid action type: xyz", + roomName: "room123" +} +``` + +--- + +### 3. **POST /api/CollaborativeEditing/GetActionsFromServer** +Retrieve pending actions from a specific version onward. + +**Request:** +```javascript +{ + roomName: "room123", + fromVersion: 3 // Get actions from version 3 onwards +} +``` + +**Response:** +```javascript +{ + status: "success", + roomName: "room123", + fromVersion: 3, + currentVersion: 6, + actions: [ + { + version: 4, + connectionId: "conn-456", + userName: "Alice", + type: "annotation", + timestamp: "2026-09-04T10:30:00Z", + data: {...} + }, + // ... more actions + ] +} +``` + +--- + +### 4. **POST /api/CollaborativeEditing/SaveRequest** +Process save request and manage Redis action cleanup. + +**Request:** +```javascript +{ + roomName: "room123", + connectionId: "conn-456", + saveType: "full", // or "partial" + partialSave: true // true for incremental, false for complete cleanup +} +``` + +**Response:** +```javascript +{ + status: "success", + roomName: "room123", + message: "Save request processed", + actionsRemoved: 2, + remainingActions: 3 +} +``` + +--- + +### 5. **GET /api/test** +Health check endpoint. + +**Response:** +```javascript +{ + status: "PDF Viewer Collaboration Server Running" +} +``` + +--- + +## 🔐 Configuration + +### Redis Configuration (`server.js`) + +```javascript +redis: { + host: 'your-host', // Redis host/endpoint + port: 6380, // Redis port (6380 for Azure) + username: 'default', // Redis username + password: 'your-password', // Redis password + tls: {} // Enable TLS for secure connections +} +``` + +### Save Threshold Configuration + +The `SaveThreshold` setting controls how many actions are removed during a partial save: + +```javascript +// Set in environment or server config +SaveThreshold: 2 // Remove first 2 actions on partial save +``` + +**Behavior:** +- **Partial Save**: Removes first N actions (N = SaveThreshold), retains remaining +- **Full Save**: Removes ALL actions for the room + +--- + +## 🔄 Request/Response Flow + +### Typical Collaboration Session + +``` +1. Client Connects + ├─ POST /ImportFile + ├─ Receives: Latest snapshots + version number + └─ Joins room with connection ID + +2. Client Makes Change + ├─ POST /UpdateAction (e.g., add annotation) + ├─ Server stores action in Redis + ├─ Server broadcasts to other clients via WebSocket + └─ Returns updated version number + +3. Client Polls for Updates + ├─ POST /GetActionsFromServer (fromVersion: 5) + ├─ Server returns actions 6, 7, 8, ... + └─ Client applies updates to local state + +4. Client Saves + ├─ Client creates final PDF blob via saveAsBlob() + ├─ Client uploads PDF via separate endpoint + ├─ POST /SaveRequest (partialSave: true) + ├─ Server removes processed actions from Redis + └─ Returns confirmation + +5. Session End + ├─ POST /SaveRequest (partialSave: false) + └─ Server clears all pending actions +``` + +--- + +## 📊 State Management + +### Version-Based Approach + +- **No Merge Logic**: Each action is stored and broadcast independently +- **Last-Write-Wins**: For page organizer (only latest state matters) +- **Snapshots**: For annotations and form fields (complete state at version) +- **Incremental Sync**: Clients fetch actions from their last known version + +### Storage Pattern + +``` +Redis Hash: `{roomName}:actions` +├─ Version: Auto-incrementing counter +├─ Action 1: { version: 1, data: {...}, user: "Alice", timestamp: ... } +├─ Action 2: { version: 2, data: {...}, user: "Bob", timestamp: ... } +└─ Action 3: { version: 3, data: {...}, user: "Alice", timestamp: ... } +``` + +--- + +## 🚀 Testing the Server + +### 1. Health Check +```bash +curl -X GET http://localhost:8080/api/test +``` + +**Expected Output:** +```json +{ + "status": "PDF Viewer Collaboration Server Running" +} +``` + +### 2. Import File (Start Collaboration) +```bash +curl -X POST http://localhost:8080/api/CollaborativeEditing/ImportFile \ + -H "Content-Type: application/json" \ + -d '{ + "roomName": "test-room-001", + "fileName": "sample.pdf" + }' +``` + +### 3. Update Action (Send Change) +```bash +curl -X POST http://localhost:8080/api/CollaborativeEditing/UpdateAction \ + -H "Content-Type: application/json" \ + -d '{ + "roomName": "test-room-001", + "connectionId": "conn-123", + "userName": "TestUser", + "type": "annotation", + "currentVersion": 0, + "data": { + "xfdfData": "" + } + }' +``` + +### 4. Get Actions (Retrieve Changes) +```bash +curl -X POST http://localhost:8080/api/CollaborativeEditing/GetActionsFromServer \ + -H "Content-Type: application/json" \ + -d '{ + "roomName": "test-room-001", + "fromVersion": 0 + }' +``` + +--- + +## 📝 Important Notes + +### Adapter Pattern +- **Controller**: Handles HTTP requests, validation, and orchestration +- **Adapter**: Implements business logic, data transformation, and state management +- **ActionService**: Manages CRUD operations on Redis-backed actions + +### No Merge Logic +Unlike Document Editor (OT-based merging), PDF Viewer: +- Stores each action independently +- Broadcasts complete request envelope +- Clients filter responses by `connectionId` +- Page organizer uses last-write-wins + +### Save Workflow +1. Client captures final PDF state via `saveAsBlob()` +2. Client uploads PDF to backend +3. Client sends `SaveRequest` to cleanup Redis +4. Server removes processed actions based on `partialSave` flag +5. Server broadcasts `saveCompleted` event + +### Scoped Service Access +The `IActionService` is registered as SCOPED by `CollaborationServer`, but the adapter is a SINGLETON: +```javascript +// Correct: Use service scope factory +const scope = this.scopeFactory.CreateScope(); +const actionService = scope.ServiceProvider.GetService(IActionService); +``` + +--- + +## 🐛 Troubleshooting + +### Server Won't Start +- **Check Redis connection**: Verify Redis host, port, and credentials +- **Check Node.js version**: Use v14+ (v16+ recommended) +- **Install dependencies**: Run `npm install` + +### Actions Not Persisting +- **Verify Redis**: Ensure Redis server is running and accessible +- **Check Redis credentials**: Confirm password and connection settings +- **Monitor logs**: Look for connection errors in console output + +### WebSocket Disconnects +- **Check network**: Ensure client and server can communicate +- **Firewall rules**: Verify ports 8080 and 6380 are accessible +- **TLS certificate**: For Azure Redis, ensure TLS is enabled + +### Performance Issues +- **Monitor Redis memory**: High memory usage slows down operations +- **Adjust SaveThreshold**: Smaller values = more frequent cleanup +- **Check network latency**: High latency increases action delivery time + +--- + +## 📚 Additional Resources + +- **IMPLEMENTATION_GUIDE.md**: Detailed API documentation +- **MIGRATION_SUMMARY.md**: .NET to Node.js migration details +- **DIFFERENCES.md**: Document Editor vs PDF Viewer patterns +- **CHANGELOG.md**: Version history and release notes + +--- + +## 🔄 Development Workflow + +### Local Development +```bash +# Start server with automatic reload (requires nodemon) +npm install -g nodemon +nodemon server.js + +# Or manually restart +npm start +``` + +### Adding New Action Types +1. Update `collaborative-editing-controller.js` to validate new type +2. Implement type-specific snapshot logic in controller +3. Update adapter's `mapControlToGenericAction()` if needed +4. Document in IMPLEMENTATION_GUIDE.md + +### Debugging +```javascript +// Enable verbose logging in adapter +console.log('Action stored:', JSON.stringify(action, null, 2)); +console.log('Redis operation:', roomName, version); +``` + +--- + +## 📄 License + +ISC (see package.json) + +--- + +**Last Updated**: 2026-09-04 +**Server Build**: 2026-07-23 +**Node.js Requirement**: v14.0.0+ diff --git a/Collaborative Editing/Server/adapters/PdfViewerCollaborationAdapter.js b/Collaborative Editing/Server/adapters/PdfViewerCollaborationAdapter.js new file mode 100644 index 0000000..ca49771 --- /dev/null +++ b/Collaborative Editing/Server/adapters/PdfViewerCollaborationAdapter.js @@ -0,0 +1,2892 @@ +const { CollaborationServer } = require('@syncfusion/ej2-collaborator-server'); +const { PdfDocument, PdfFontFamily, PdfListFieldItem, PdfPage, PdfPageImportOptions, PdfPageSettings, DataFormat, PdfTextBoxField, PdfCheckBoxField, PdfComboBoxField, PdfListBoxField, PdfRadioButtonListField, PdfRotationAngle } = require('@syncfusion/ej2-pdf'); +const { DOMParser, XMLSerializer } = require('@xmldom/xmldom'); + +// Make DOMParser and XMLSerializer available globally for Syncfusion PDF library +if (typeof global.DOMParser === 'undefined') { + global.DOMParser = DOMParser; +} +if (typeof global.XMLSerializer === 'undefined') { + global.XMLSerializer = XMLSerializer; +} + +/** +* PDF Viewer Collaboration Adapter - Maps between unified CollaborativeEditingRequest +* and generic CollaborationAction for version-based state management. +* +* Key pattern: No merge logic. Each request is stored as-is, broadcast as-is. +* Clients filter responses via connectionId. Page organizer uses last-write-wins. +*/ +class PdfViewerCollaborationAdapter { + constructor(options = {}) { + this.saveTaskQueue = options.saveTaskQueue || null; + this.transport = options.transport || null; + this.storageService = options.storageService || null; + } + + /** + * Maps control-specific action (CollaborativeEditingRequest) to generic CollaborationAction. + * Stores the complete request as JSON in the data field. + */ + mapControlToGenericAction(controlAction) { + if (!controlAction || typeof controlAction !== 'object') { + throw new Error( + `Expected CollaborativeEditingRequest, got ${typeof controlAction}` + ); + } + + return { + roomName: controlAction.roomName || '', + connectionId: controlAction.connectionId || '', + currentUser: controlAction.userName || '', + version: controlAction.currentVersion || 0, + data: JSON.stringify(controlAction) + }; + } + + /** + * Maps generic CollaborationAction back to control-specific format for broadcast. + * Reconstructs the original unified request envelope. + */ + mapGenericToControlAction(collaborationAction) { + try { + return collaborationAction.data + ? JSON.parse(collaborationAction.data) + : {}; + } catch (e) { + console.error('Error parsing action data:', e.message); + return {}; + } + } + + /** + * No operation transformation needed for PDF Viewer. + * Each action is independently stored and broadcast. + */ + transformOperations(actions) { + // No-op: PDF Viewer uses last-write-wins or snapshot approach + } + + /** + * Replays collaborative operations against a PDF document using @syncfusion/ej2-pdf. + * + * Loads the master document, applies pending operations (annotations, form fields, + * page organizer), and saves the updated PDF as a Buffer. + * + * Uses server-side PDF manipulation for better performance (no JSDOM required). + * + * @param {string} masterPdfBase64 - Master PDF encoded as base64 + * @param {Array} operations - Pending operations to replay + * @returns {Promise} Updated PDF as Blob (ready for upload) + */ + async replayOperationsAndUpdateDocument(masterPdfBase64, operations) { + try { + if (!masterPdfBase64 || masterPdfBase64.length === 0) { + throw new Error('Master PDF is empty or not provided'); + } + + // Convert base64 to Buffer + const pdfBuffer = Buffer.from(masterPdfBase64, 'base64'); + if (pdfBuffer.length === 0) { + throw new Error('PDF Buffer is empty after conversion from base64'); + } + + // Load PDF document using Syncfusion PDF library + const document = new PdfDocument(pdfBuffer); + + if (document.pageCount === 0) { + console.warn('[ReplayOps] WARNING: Loaded PDF has 0 pages'); + } + + // Extract and validate operations + const validOperations = this._extractValidOperations(operations); + + if (validOperations.length > 0) { + + let successCount = 0; + let failureCount = 0; + + for (let i = 0; i < validOperations.length; i++) { + const operation = validOperations[i]; + try { + await this.applyOperationToDocument(document, operation); + successCount++; + } catch (opError) { + console.error( + `[ReplayOps] Failed to apply operation ${i + 1} (${operation.type}): ${opError.message}` + ); + failureCount++; + // Continue with next operation instead of throwing + } + } + + } else { + console.warn('[ReplayOps] No valid operations to replay'); + } + + // Save updated PDF as Buffer + const updatedPdfBuffer = await document.save(); + + if (!updatedPdfBuffer || updatedPdfBuffer.length === 0) { + throw new Error('Failed to save PDF: document.save() returned empty buffer'); + } + + // Convert Buffer to Blob for compatibility + const updatedPdfBlob = new Blob([updatedPdfBuffer], { type: 'application/pdf' }); + + return updatedPdfBlob; + } catch (ex) { + console.error('[ReplayOps] Error during operation replay:', ex.message); + console.error('[ReplayOps] Stack:', ex.stack); + throw ex; + } + } + + /** + * Extracts valid operations from the operations array. + * Handles various operation formats (wrapped and unwrapped). + * + * @param {Array} operations - Raw operations array + * @returns {Array} Array of valid operation objects + */ + _extractValidOperations(operations) { + try { + if (!Array.isArray(operations)) { + console.warn('[ExtractOps] Operations is not an array:', typeof operations); + return []; + } + + const validOperations = []; + + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + + if (!op) { + console.warn(`[ExtractOps] Operation ${i} is null or undefined`); + continue; + } + + // Case 1: Direct operation with type and data + if (op.type && op.data) { + validOperations.push(op); + continue; + } + + // Case 2: Operation wrapped in CollaborationAction format with data as JSON string + if (op.data && typeof op.data === 'string') { + try { + const fieldData = JSON.parse(op.data); + if (fieldData.type === 'annotation') { + validOperations.push({ + type: fieldData.type, + data: fieldData.data.xfdfData, + action: fieldData.data.action + }); + } else if (fieldData.type === 'pageOrganizer') { + const parsedData = JSON.parse(fieldData.data); + validOperations.push({ + type: fieldData.type, + data: parsedData[0], + action: parsedData[0].action + }); + } else if (fieldData.type === 'formField') { + const parsedData = JSON.parse(fieldData.data.jsonData); + validOperations.push({ + type: fieldData.type, + data: parsedData, + action: fieldData.data.action + }); + } + else if (fieldData.type === 'formFieldAction') { + const parsedData = JSON.parse(fieldData.data.changes); + const data = parsedData.created.length > 0 ? parsedData.created[0] : (parsedData.updated.length ? parsedData.updated[0] : parsedData.deleted[0]); + const action = parsedData.created.length > 0 ? 'created' : (parsedData.updated.length ? 'updated' : parsedData.deleted.length > 0 ? 'deleted' : ''); + // Extract the actual action from parsed data + if (fieldData.actionType) { + validOperations.push({ + type: fieldData.actionType, + data: data, + action: action + }); + } else if (fieldData.type) { + validOperations.push({ + type: fieldData.type, + data: data, + action: action + }); + } + } + } catch (parseError) { + console.warn( + `[ExtractOps] Failed to parse operation ${i} data as JSON:`, + parseError.message + ); + } + } + + // Case 3: Operation has nested action object + if (op.action && typeof op.action === 'string') { + validOperations.push({ + type: op.action, + data: op, + action: action + }); + } + } + + // Log operation types for debugging + const opTypes = validOperations.map(op => op.type); + + return validOperations; + } catch (ex) { + console.error('[ExtractOps] Error extracting operations:', ex.message); + return []; + } + } + + /** + * Applies a single operation to the PDF document based on operation type. + * + * Handles: + * - annotation / annotationUpdate: Apply annotation data via XFDF + * - formField / formFieldAction: Apply form field updates + * - pageOrganizer: Apply page rearrangement/deletion + * - addUser / removeUser: User presence (ignored on save) + * - connectionId: Connection echo (ignored on save) + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} operation - Operation to apply (must have type and data) + */ + async applyOperationToDocument(document, operation) { + if (!operation) { + console.warn('[ApplyOp] Skipping null operation'); + return; + } + + const opType = operation.type || 'unknown'; + const opData = operation.data || {}; + + try { + + switch (opType.toLowerCase()) { + case 'annotation': + case 'annotationupdate': + // Apply annotation data via XFDF import + if (opData) { + const xfdfData = opData; + await this._importAnnotationsFromXfdf(document, xfdfData, operation.action); + } else { + console.warn('[ApplyOp] No XFDF data found in annotation operation'); + } + break; + + case 'formfield': + case 'formfieldaction': + case 'formfieldupdate': + // Apply form field updates + await this._applyFormFieldUpdates(document, opData, operation.type, operation.action); + break; + + case 'pageorganizer': + case 'pageOrganizer': + case 'pageorganizeractions': + // Apply page rearrangement/deletion (last-write-wins) + await this._applyPageOrganizerActions(document, opData); + break; + + // Ignore user presence operations + case 'adduser': + case 'removeuser': + case 'connectionid': + break; + + default: + console.warn(`[ApplyOp] Unknown operation type: ${opType}`); + } + + } catch (ex) { + console.error( + `[ApplyOp] Error applying operation (${opType}): ${ex.message}` + ); + console.error('[ApplyOp] Operation data:', opData); + throw ex; + } + } + + /** + * Imports annotations from XFDF data into the PDF document. + * Parses XFDF, handles changesets (add/modify/delete), generates pure XFDF, + * and applies annotations to the document. + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {string} xfdfData - XFDF XML string containing annotations + * @param {string} action - Action type (add/modify/delete) + */ + async _importAnnotationsFromXfdf(document, xfdfData, action) { + try { + if (typeof xfdfData !== 'string' || xfdfData.trim() === '') { + throw new Error('Unrecognized XFDF structure'); + } + // Parse XFDF document + const xmlDocument = this._parseXfdfDocument(xfdfData, 'importAnnotationCommand'); + const xfdfElement = xmlDocument.documentElement; + // Get direct child elements + const addElement = this._getDirectChildElement(xfdfElement, 'add'); + const modifyElement = this._getDirectChildElement(xfdfElement, 'modify'); + const deleteElement = this._getDirectChildElement(xfdfElement, 'delete'); + let pureXfdf = null; + const annotationElements = []; + if (action === 'Add' && addElement && addElement.children.length > 0) { + const addElements = this._getSupportedAnnotationElements(addElement); + annotationElements.push(...addElements); + } + else if (action === 'Modify' && modifyElement && modifyElement.children.length > 0) { + this._removeAnnotations(modifyElement, document); + const modElements = this._getSupportedAnnotationElements(modifyElement); + annotationElements.push(...modElements); + } + else if (action === 'Delete' && deleteElement && deleteElement.children.length > 0) { + this._removeAnnotations(deleteElement, document); + } + if (annotationElements.length > 0) { + const serializedElements = annotationElements.map(element => this._serializeXmlNode(element)); + pureXfdf = this._createPureXfdf(serializedElements); + } + // Import pure XFDF to document + if (pureXfdf) { + document.importAnnotations(new TextEncoder().encode(pureXfdf), DataFormat.xfdf); + } else { + console.warn('[ImportXfdf] No valid annotations found to import'); + } + } catch (ex) { + console.error('[ImportXfdf] Error importing XFDF:', ex.message); + console.error('[ImportXfdf] Stack:', ex.stack); + throw ex; + } + } + + /** + * Parses XFDF data into an XML document. + * Validates XML structure and throws error if parsing fails. + * + * @param {string} data - XFDF XML string + * @param {string} commandName - Command name for error reporting + * @returns {Document} Parsed XML document + */ + _parseXfdfDocument(data, commandName) { + try { + const parser = new DOMParser(); + const xmlDocument = parser.parseFromString(data, 'text/xml'); + + // Check for parse errors + const parseErrors = xmlDocument.getElementsByTagName('parsererror'); + if (parseErrors && parseErrors.length > 0) { + const parseError = parseErrors[0]; + const errorMsg = parseError.textContent || 'Malformed XML'; + throw new Error(`${commandName}: ${errorMsg}`); + } + + return xmlDocument; + } catch (ex) { + console.error(`[ParseXfdf] Error parsing XFDF: ${ex.message}`); + throw ex; + } + } + + /** + * Gets the first direct child element with the specified name. + * + * @param {Element} parent - Parent element + * @param {string} elementName - Name of child element to find + * @returns {Element|null} Found element or null + */ + _getDirectChildElement(parent, elementName) { + if (!parent) { + return null; + } + for (let i = 0; i < parent.childNodes.length; i++) { + const childNode = parent.childNodes[i]; + if (childNode.nodeType === 1) { // Element node + if (this._getNodeName(childNode) === elementName) { + return childNode; + } + } + } + return null; + } + + /** + * Gets the local or node name of an element, lowercased. + * + * @param {Element} element - XML element + * @returns {string} Element name in lowercase + */ + _getNodeName(element) { + const localName = element.localName || element.nodeName; + return localName ? localName.toLowerCase() : ''; + } + + /** + * Removes annotations from the PDF document based on annotation data in a container element. + * Iterates through child annotation elements and removes matching annotations from the loaded document. + * This method performs side effects (removes annotations) and does not return any value. + * + * @param {Element} container - Container element with annotation children (add/modify elements) + * @param {PdfDocument} document - Syncfusion PdfDocument instance to remove annotations from + * @returns {void} No return value - performs annotation removal as side effect + */ + _removeAnnotations(container, document) { + if (!container || !container.childNodes) { + return; + } + for (let i = 0; i < container.childNodes.length; i++) { + const childNode = container.childNodes[i]; + // Skip non-element nodes (text, comments, etc.) + if (childNode.nodeType !== 1) { + continue; + } + const id = childNode.getAttribute('name'); + const pageIndex = Number(childNode.getAttribute('page')); + if (id) { + this.removeAnnotationsInLoadedDocument(id, pageIndex, document); + break; + } + } + } + + /** + * Removes annotations from the PDF document by matching the annotation ID. + * Searches through all annotations on a specific page and removes the one with matching ID. + * Iterates in reverse order to safely remove items during iteration. + * Does not return any value - performs removal as a side effect. + * + * @param {string} id - The annotation ID to match and remove + * @param {number} pageIndex - Zero-based page index where the annotation is located + * @param {PdfDocument} document - Syncfusion PdfDocument instance containing the annotations + * @returns {void} No return value - performs annotation removal as side effect + */ + removeAnnotationsInLoadedDocument(id, pageIndex, document) { + const page = document.getPage(pageIndex); + if (page && page.annotations && page.annotations.count > 0) { + // Iterate in reverse to safely remove items + for (let j = page.annotations.count - 1; j >= 0; j--) { + const annotationObject = page.annotations.at(j); + if (annotationObject && annotationObject.name === id) { + page.annotations.removeAt(j); + break; // Remove only the matching annotation + } + } + } + } + + /** + * Filters and returns only supported annotation element types. + * Excludes elements that are not annotation data. + * + * @param {Element} container - Container element with potential annotation children + * @returns {Array} Array of supported annotation elements + */ + _getSupportedAnnotationElements(container) { + const supportedTypes = [ + 'highlight', 'underline', 'strikeout', 'squiggly', 'square', 'circle', 'line', + 'polygon', 'polyline', 'freetext', 'ink', 'stamp', 'text', 'redact' + ]; + const elements = []; + if (!container || !container.childNodes) { + return elements; + } + for (let i = 0; i < container.childNodes.length; i++) { + const childNode = container.childNodes[i]; + // Skip non-element nodes (text, comments, etc.) + if (childNode.nodeType !== 1) { + continue; + } + const element = childNode; + if (supportedTypes.includes(this._getNodeName(element))) { + elements.push(element); + } else { + console.warn(`[GetSupportedAnnots] Skipping unsupported annotation type: ${this._getNodeName(element)}`); + } + } + return elements; + } + + /** + * Serializes an XML node to string representation. + * + * @param {Node} node - XML node to serialize + * @returns {string} Serialized XML string + */ + _serializeXmlNode(node) { + return new XMLSerializer().serializeToString(node); + } + + /** + * Creates a pure XFDF document from annotation element strings using DOM API. + * Constructs valid XFDF structure with annots container using proper DOM methods. + * Avoids hardcoded XML strings and ensures proper namespace handling. + * + * @param {Array} annotationElements - Serialized annotation XML strings + * @returns {string} Complete XFDF XML string with proper namespace + */ + _createPureXfdf(annotationElements) { + try { + const XFDF_XMLNS = 'http://ns.adobe.com/xfdf/'; + // Create a blank XFDF document using DOM API + const xmlDoc = this._createBlankXfdfDocument(XFDF_XMLNS); + const rootElement = xmlDoc.documentElement; + const annotsElement = xmlDoc.createElementNS(XFDF_XMLNS, 'annots'); + let successCount = 0; + const parser = new DOMParser(); + // Parse and append annotation elements safely using proper DOM methods + for (let i = 0; i < annotationElements.length; i++) { + const elementStr = annotationElements[i]; + try { + if (!elementStr || elementStr.trim() === '') { + console.warn(`[CreatePureXfdf] Element ${i} is empty, skipping`); + continue; + } + // Parse the annotation element string + const tempDoc = parser.parseFromString(elementStr, 'text/xml'); + // Check for parse errors + if (tempDoc.documentElement && tempDoc.documentElement.nodeName !== 'parsererror') { + const element = tempDoc.documentElement; + const tagName = this._getNodeName(element); + // Import and append the element using DOM API + const importedNode = xmlDoc.importNode(element, true); + annotsElement.appendChild(importedNode); + successCount++; + } else { + console.warn(`[CreatePureXfdf] Element ${i} has parse error, skipping`); + } + } catch (error) { + console.warn(`[CreatePureXfdf] Element ${i} error: ${error.message}`); + continue; + } + } + if (successCount === 0) { + throw new Error('No valid annotation elements found to create XFDF'); + } + // Append annots element to root + rootElement.appendChild(annotsElement); + // Serialize to string + const serializer = new XMLSerializer(); + return serializer.serializeToString(xmlDoc); + } catch (ex) { + console.error('[CreatePureXfdf] Error creating pure XFDF:', ex.message); + console.error('[CreatePureXfdf] Stack:', ex.stack); + throw ex; + } + } + + /** + * Creates a blank XFDF document with proper namespace and structure. + * Works in both browser and Node.js environments using DOMParser. + * + * @param {string} xfdfNamespace - XFDF XML namespace URI + * @returns {XMLDocument} Properly initialized XFDF document + */ + _createBlankXfdfDocument(xfdfNamespace) { + try { + const parser = new DOMParser(); + // Create XFDF document by parsing an XML string + // This approach works in both browser and Node.js environments + const xfdfString = ` `; + const xmlDoc = parser.parseFromString(xfdfString, 'text/xml'); + // Validate parsing + if (xmlDoc.documentElement && xmlDoc.documentElement.nodeName !== 'parsererror') { + return xmlDoc; + } else { + throw new Error('Failed to parse XFDF template'); + } + } catch (ex) { + console.error('[CreateBlankXfdf] Error creating blank XFDF document:', ex.message); + throw ex; + } + } + + /** + * Applies form field updates to the PDF document. + * Handles two modes: + * - formField: Simple value updates on existing fields + * - formFieldAction: Creates new fields in Form Designer mode or updates existing ones + * + * Checks if field already exists. If yes, updates the latest value. If no, creates new field. + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} formFieldData - Form field update data + */ + async _applyFormFieldUpdates(document, formFieldData, type, action) { + try { + + // Case 1: formField type - update existing field values only + if (type === 'formField') { + await this._updateFormFieldValues(document, formFieldData); + } + // Case 2: formFieldAction type - add new fields or update existing ones in Form Designer mode + else if (type === 'formFieldAction') { + if (action === 'deleted' || action === 'updated') { + for (let i = 0; i < document.form.count; i++) { + if (document.form.fieldAt(i).name === formFieldData.name) { + if (formFieldData.formFieldAnnotationType === 'InitialField' || formFieldData.formFieldAnnotationType === 'SignatureField') { + if (Math.floor(document.form.fieldAt(i).bounds.x) === Math.floor(formFieldData.lineBound.X) && + Math.floor(document.form.fieldAt(i).bounds.y) === Math.floor(formFieldData.lineBound.Y) && + (document.form.fieldAt(i).page._pageIndex + 1 === formFieldData.pageNumber)) { + document.form.removeFieldAt(i); + } + } + else if (Math.floor(document.form.fieldAt(i).bounds.x) === Math.floor(this.convertPixelToPoint(formFieldData.bounds.x)) && + Math.floor(document.form.fieldAt(i).bounds.y) === Math.floor(this.convertPixelToPoint(formFieldData.bounds.y)) && + (document.form.fieldAt(i).page._pageIndex + 1 === formFieldData.pageNumber)) { + document.form.removeFieldAt(i); + } + } + } + } + if (action === 'created' || action === 'updated') { + await this._handleFormFieldAction(document, formFieldData); + } + + if (document.form) { + for (let i = 0; i < document.form.count; i++) { + const field = document.form.fieldAt(i); + field.setAppearance(true); + } + } + } + } catch (ex) { + console.error('[ApplyFormFields] Error applying form field updates:', ex.message); + console.error('[ApplyFormFields] Full error:', ex); + throw ex; + } + } + + /** + * Updates existing form field values using field name mapping. + * Properly handles all field types: TextBox, CheckBox, RadioButton, ComboBox, ListBox. + * Ignores SignatureField and InitialField types. + * + * Implements the Syncfusion pattern: + * 1. Retrieve field name (both stripped and actual) + * 2. Match against input data object keys + * 3. Update based on field type and value + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} formFieldData - Object with field names as keys and values as values: { "fieldName": value, ... } + */ + async _updateFormFieldValues(document, formFieldData) { + try { + + // Get total form field count from document + if (!document.form) { + console.warn('[UpdateValues] No form found in document'); + return; + } + + const fieldCount = document.form.count || (document.form._fields ? document.form._fields.length : 0); + if (fieldCount === 0) { + console.warn('[UpdateValues] No form fields found in document'); + return; + } + + // Input data mapping object: { "fieldName": value } + // Handle both string and object formats + let data = formFieldData; + if (typeof formFieldData === 'string') { + try { + data = JSON.parse(formFieldData); + } catch (e) { + console.error('[UpdateValues] Failed to parse formFieldData as JSON:', e.message); + return; + } + } + + if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { + console.warn('[UpdateValues] No field data provided for update'); + return; + } + + let updatedCount = 0; + + // Iterate through all document form fields + for (let i = 0; i < fieldCount; i++) { + const currentField = document.form.fieldAt(i); + if (!currentField) { + continue; + } + + // Get field names: stripped (no special chars) and actual + let currentFieldName = ''; + let actualFieldName = ''; + + if (currentField.name) { + // Stripped name: remove all non-alphanumeric chars and spaces + currentFieldName = currentField.name.replace(/[^0-9a-zA-Z]+/g, '').replace(/\s+/g, ''); + // Actual name: original with spaces and special chars + actualFieldName = currentField.name; + } + + const fieldType = currentField.constructor ? currentField.constructor.name : ''; + // Check if field data exists for this field (by stripped or actual name) + let hasData = false; + let fieldDataKey = null; + + if (Object.prototype.hasOwnProperty.call(data, currentFieldName)) { + hasData = true; + fieldDataKey = currentFieldName; + } else if (Object.prototype.hasOwnProperty.call(data, actualFieldName)) { + hasData = true; + fieldDataKey = actualFieldName; + } + + if (!hasData) { + continue; + } + + const fieldValue = data[fieldDataKey]; + + // Handle field types: TextBox, CheckBox, RadioButton, ComboBox, ListBox + // Skip: SignatureField, InitialField + + // TextBox / Password Field + if (currentField instanceof PdfTextBoxField) { + if (fieldValue !== null && fieldValue !== undefined) { + currentField.text = String(fieldValue); + currentField.value = String(fieldValue); + updatedCount++; + } + } + // CheckBox Field + else if (currentField instanceof PdfCheckBoxField) { + const fieldValueString = String(fieldValue).toLowerCase(); + const isChecked = fieldValueString === 'true' || fieldValueString === 'yes' || fieldValue === true; + + currentField.checked = isChecked; + updatedCount++; + } + // RadioButton / RadioButtonList Field + else if (currentField instanceof PdfRadioButtonListField) { + const selectedValue = String(fieldValue); + const itemsCount = currentField.itemsCount || 0; + + for (let j = 0; j < itemsCount; j++) { + const item = currentField.itemAt(j); + if (item && (item.value === selectedValue || item._optionValue === selectedValue)) { + currentField.selectedIndex = j; + updatedCount++; + break; + } + } + } + // ComboBox / Dropdown Field + else if (currentField instanceof PdfComboBoxField) { + const selectedValue = String(fieldValue); + const itemsCount = currentField.itemsCount || 0; + let isFound = false; + + // Try to match option text or value + for (let j = 0; j < itemsCount; j++) { + let optionText = null; + + // Try itemAt() method + if (currentField.itemAt && currentField.itemAt(j)) { + optionText = currentField.itemAt(j).text; + } + // Try _dictionary approach + else if (currentField._dictionary && currentField._dictionary.has('Opt')) { + const optionArray = currentField._dictionary.get('Opt'); + optionText = optionArray[j]; + } + + if (optionText && (optionText === selectedValue || (Array.isArray(optionText) && optionText[1] === selectedValue))) { + currentField.selectedIndex = j; + isFound = true; + updatedCount++; + break; + } + } + + // If not found in options and field is editable, set selectedValue directly + if (!isFound && currentField.editable) { + currentField.selectedValue = selectedValue; + updatedCount++; + } + } + // ListBox Field + else if (currentField instanceof PdfListBoxField) { + const selectedValue = String(fieldValue); + const itemsCount = currentField.itemsCount || 0; + const selectedIndexes = []; + + // Match option text + for (let j = 0; j < itemsCount; j++) { + const optionText = currentField.itemAt(j) ? currentField.itemAt(j).text : null; + if (optionText) { + const strippedText = optionText.replace(/[^0-9a-zA-Z]+/g, ''); + const strippedValue = selectedValue.replace(/[^0-9a-zA-Z]+/g, ''); + if (strippedText === strippedValue) { + selectedIndexes.push(j); + } + } + } + + if (selectedIndexes.length > 0) { + currentField.selectedIndex = selectedIndexes; + updatedCount++; + } + } + } + + return document; + } catch (ex) { + console.error('[UpdateValues] Error updating field values:', ex.message); + console.error('[UpdateValues] Stack:', ex.stack); + throw ex; + } + } + + /** + * Handles Form Designer action - create or update fields. + * Checks if field exists; if yes, updates it; if no, creates it. + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} formFieldData - Form Designer action data + */ + async _handleFormFieldAction(document, formFieldData) { + try { + const fieldAttributes = formFieldData.fieldAttributes || formFieldData; + const fieldName = fieldAttributes.fieldName || fieldAttributes.name; + + await this._createFormField(document, fieldAttributes); + } catch (ex) { + console.error('[FormFieldAction] Error handling field action:', ex.message); + throw ex; + } + } + + /** + * Updates properties of an existing form field. + * Ensures changes are properly persisted to the document. + * + * @param {PdfField} field - Existing PDF field to update + * @param {Object} attributes - New field attributes + */ + async _updateExistingField(field, attributes) { + try { + if (!field) { + console.warn('[UpdateField] Field object is null or undefined'); + return; + } + // Update value - try multiple property names + if (attributes.value !== undefined) { + field.value = attributes.value; + field.Value = attributes.value; + field.text = attributes.value; + field.Text = attributes.value; + } + + // Update visibility - try multiple property names + if (attributes.visibility !== undefined) { + field.visibility = attributes.visibility; + field.Visibility = attributes.visibility; + } + + // Update read-only status - try multiple property names + if (attributes.readOnly !== undefined) { + field.readOnly = attributes.readOnly; + field.ReadOnly = attributes.readOnly; + } + + // Update required status - try multiple property names + if (attributes.required !== undefined) { + field.required = attributes.required; + field.Required = attributes.required; + } + + // Update field-specific properties + if (attributes.multiLine !== undefined) { + field.multiLine = attributes.multiLine; + field.MultiLine = attributes.multiLine; + } + + if (attributes.maxLength !== undefined) { + field.maxLength = attributes.maxLength; + field.MaxLength = attributes.maxLength; + } + + if (attributes.insertSpaces !== undefined) { + field.insertSpaces = attributes.insertSpaces; + field.InsertSpaces = attributes.insertSpaces; + } + + // Update font properties if available + if (attributes.fontSize !== undefined) { + if (field.font) { + field.font.size = attributes.fontSize; + } + } + + // Update colors if available + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + field.backColor = bgColor; + field.BackColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + field.color = fontColor; + field.Color = fontColor; + } + + } catch (ex) { + console.error('[UpdateField] Error updating field:', ex.message); + console.error('[UpdateField] Stack:', ex.stack); + throw ex; + } + } + + /** + * Creates a new form field in the PDF document. + * Supports TextBox, CheckBox, RadioButton, DropDown, ListBox, and Signature fields. + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} fieldAttributes - Field attributes including type, bounds, page number, etc. + */ + async _createFormField(document, fieldAttributes) { + try { + const fieldType = fieldAttributes.fieldType || fieldAttributes.type || 'TextBox'; + const pageNumber = fieldAttributes.pageNumber || fieldAttributes.pageIndex || 0; + const fieldName = fieldAttributes.fieldName || fieldAttributes.name; + + // // Validate page number + // if (pageNumber < 0 || pageNumber >= document.pageCount) { + // console.warn( + // `[CreateField] Invalid page number ${pageNumber}. Document has ${document.pageCount} pages` + // ); + // return; + // } + + const page = document.getPage(pageNumber - 1); + if (!page) { + console.warn(`[CreateField] Could not get page ${pageNumber}`); + return; + } + + // Parse bounds if provided + let bounds = this._parseBounds(fieldAttributes.bounds); + + // Create field based on type + let field = null; + + switch (fieldType.toLowerCase()) { + case 'textbox': + case 'text': + case 'password': + field = this._createTextBoxField(page, fieldName, fieldAttributes, bounds); + break; + + case 'checkbox': + case 'checkboxfield': + field = this._createCheckBoxField(page, fieldName, fieldAttributes, bounds); + break; + + case 'radiobutton': + case 'radiobuttonfield': + field = this._createRadioButtonField(page, fieldName, fieldAttributes, bounds); + break; + + case 'dropdown': + case 'combobox': + case 'comboboxfield': + field = this._createDropDownField(page, fieldName, fieldAttributes, bounds); + break; + + case 'listbox': + case 'listboxfield': + field = this._createListBoxField(page, fieldName, fieldAttributes, bounds); + break; + + case 'signature': + case 'signaturefield': + case 'initial': + case 'initialfield': + // if (fieldAttributes.lineBound) { + // bounds = fieldAttributes.lineBound; + // } + field = this._createSignatureField(page, fieldName, fieldAttributes, bounds); + break; + + default: + console.warn(`[CreateField] Unknown field type: ${fieldType}`); + return; + } + + if (field) { + const id = fieldAttributes.id; + if (field._dictionary) { + field._dictionary.set('Id', id); + } + // Validate document form exists + if (!document.form) { + console.error('[CreateField] Document form is undefined or null'); + return; + } + + // Add field to form + try { + document.form.add(field); + } catch (addError) { + console.error('[CreateField] Error adding field to form:', addError.message); + throw addError; + } + } else { + console.warn(`[CreateField] Field creation returned null for type: ${fieldType}`); + } + } catch (ex) { + console.error('[CreateField] Error creating form field:', ex.message); + console.error('[CreateField] Stack trace:', ex.stack); + throw ex; + } + } + + /** + * Parses color from various formats (object with r,g,b,a, hex string, or named color). + * Returns RGB object compatible with PdfDocument color properties. + * + * @param {*} colorData - Color in various formats + * @returns {Object} Parsed color object {r, g, b} or {r, g, b, a} + */ + _parseColor(colorData) { + try { + if (!colorData) { + return { r: 0, g: 0, b: 0 }; + } + + // Already RGBA object + if (typeof colorData === 'object' && colorData.r !== undefined && colorData.g !== undefined && colorData.b !== undefined) { + return { + r: Math.min(255, Math.max(0, colorData.r)), + g: Math.min(255, Math.max(0, colorData.g)), + b: Math.min(255, Math.max(0, colorData.b)), + a: colorData.a !== undefined ? colorData.a : 255 + }; + } + + // Hex string (e.g., "#FF0000" or "FF0000") + if (typeof colorData === 'string' && colorData.startsWith('#')) { + const hex = colorData.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return { r, g, b, a: 255 }; + } + + // Named colors + const namedColors = { + 'red': { r: 255, g: 0, b: 0 }, + 'green': { r: 0, g: 128, b: 0 }, + 'blue': { r: 0, g: 0, b: 255 }, + 'black': { r: 0, g: 0, b: 0 }, + 'white': { r: 255, g: 255, b: 255 }, + 'gray': { r: 128, g: 128, b: 128 }, + 'yellow': { r: 255, g: 255, b: 0 }, + 'cyan': { r: 0, g: 255, b: 255 }, + 'magenta': { r: 255, g: 0, b: 255 } + }; + + if (typeof colorData === 'string' && namedColors[colorData.toLowerCase()]) { + return { ...namedColors[colorData.toLowerCase()], a: 255 }; + } + + return { r: 0, g: 0, b: 0, a: 255 }; + } catch (ex) { + console.warn('[ParseColor] Error parsing color:', ex.message); + return { r: 0, g: 0, b: 0, a: 255 }; + } + } + + /** + * Gets PdfFontFamily enum value based on font name. + * + * @param {string} fontName - Font name (e.g., "Helvetica", "Times New Roman", "Courier") + * @returns {*} PdfFontFamily enum value + */ + _getFontFamily(fontName) { + try { + // const { PdfFontFamily } = require('@syncfusion/ej2-pdf'); + + if (!fontName) return PdfFontFamily.helvetica; + + const fontNameLower = fontName.toLowerCase(); + + if (fontNameLower.includes('times')) return PdfFontFamily.timesRoman; + if (fontNameLower.includes('courier')) return PdfFontFamily.courier; + if (fontNameLower.includes('symbol')) return PdfFontFamily.symbol; + if (fontNameLower.includes('zapf')) return PdfFontFamily.zapfDingbats; + + return PdfFontFamily.helvetica; + } catch (ex) { + console.warn('[GetFontFamily] Error:', ex.message); + return null; + } + } + + /** + * Gets PdfFontStyle enum value based on attributes. + * Supports bold, italic, underline, strikethrough. + * + * @param {Object} attributes - Field attributes containing font style flags + * @returns {*} PdfFontStyle enum value + */ + _getPdfFontStyle(attributes) { + try { + const { PdfFontStyle } = require('@syncfusion/ej2-pdf'); + + let style = PdfFontStyle.regular; + + if (attributes.bold) { + style |= PdfFontStyle.bold; + } + + if (attributes.italic) { + style |= PdfFontStyle.italic; + } + + if (attributes.underline) { + style |= PdfFontStyle.underline; + } + + if (attributes.strikethrough) { + style |= PdfFontStyle.strikeout; + } + + return style; + } catch (ex) { + console.warn('[GetPdfFontStyle] Error:', ex.message); + return null; + } + } + + /** + * Gets text alignment enum value. + * + * @param {string} align - Alignment value (e.g., "Left", "Center", "Right", "Justified") + * @returns {*} PdfTextAlignment enum value + */ + _getTextAlignment(align) { + try { + const { PdfTextAlignment } = require('@syncfusion/ej2-pdf'); + + if (!align) return PdfTextAlignment.left; + + const alignLower = align.toLowerCase(); + + if (alignLower === 'center') return PdfTextAlignment.center; + if (alignLower === 'right') return PdfTextAlignment.right; + if (alignLower === 'justify') return PdfTextAlignment.justify; + + return PdfTextAlignment.left; + } catch (ex) { + console.warn('[GetTextAlignment] Error:', ex.message); + return null; + } + } + + /** + * Parses bounds from various formats (object, string, array). + * Returns default bounds if not provided. + * + * @param {*} boundsData - Bounds data in various formats + * @returns {Object} Parsed bounds object {x, y, width, height} + */ + _parseBounds(boundsData) { + // Default bounds + const defaultBounds = { x: 0, y: 0, width: 100, height: 20 }; + + if (!boundsData) { + return defaultBounds; + } + + try { + // If already an object with x, y, width, height + if (typeof boundsData === 'object' && (boundsData.x !== undefined || boundsData.X !== undefined) && (boundsData.y !== undefined || boundsData.Y !== undefined)) { + return { + x: this.convertPixelToPoint(boundsData.x !== undefined ? boundsData.x : boundsData.X), + y: this.convertPixelToPoint(boundsData.y !== undefined ? boundsData.y : boundsData.Y), + width: this.convertPixelToPoint(boundsData.width !== undefined ? boundsData.width : (boundsData.Width !== undefined ? boundsData.Width : 100)), + height: this.convertPixelToPoint(boundsData.height !== undefined ? boundsData.height : (boundsData.Height !== undefined ? boundsData.Height : 20)) + }; + } + + // If string representation + if (typeof boundsData === 'string') { + const parsed = JSON.parse(boundsData); + return this._parseBounds(parsed); + } + + // If array [x, y, width, height] + if (Array.isArray(boundsData) && boundsData.length >= 4) { + return { + x: this.convertPixelToPoint(boundsData[0]), + y: this.convertPixelToPoint(boundsData[1]), + width: this.convertPixelToPoint(boundsData[2]), + height: this.convertPixelToPoint(boundsData[3]) + }; + } + } catch (ex) { + console.warn('[ParseBounds] Error parsing bounds:', ex.message); + } + + return defaultBounds; + } + + convertPixelToPoint(value) { + return (value * 72 / 96); + } + + convertPointtoPixel(value) { + return (value * 96 / 72); + } + + /** + * Creates a TextBox form field with comprehensive property support. + * Supports all text, styling, validation, and visual properties. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + * @returns {PdfTextBoxField|null} + */ + _createTextBoxField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateTextField] Page object is undefined or null'); + return null; + } + + const { PdfTextBoxField, PdfStandardFont, PdfFontStyle } = require('@syncfusion/ej2-pdf'); + + const textBox = new PdfTextBoxField(page, fieldName, bounds); + + // === TEXT AND VALUE PROPERTIES === + if (attributes.value !== undefined) { + textBox.text = attributes.value; + } + if (attributes.formFieldAnnotationType === 'PasswordField') { + textBox.password = true; + } + + // === FIELD BEHAVIOR === + if (attributes.isMultiline !== undefined) { + textBox.multiLine = attributes.isMultiline; + } + + if (attributes.isReadonly !== undefined) { + textBox.readOnly = attributes.isReadonly; + } + + if (attributes.isRequired !== undefined) { + textBox.required = attributes.isRequired; + } + + if (attributes.maxLength !== undefined && attributes.maxLength > 0) { + textBox.maxLength = attributes.maxLength; + } + + if (attributes.insertSpaces !== undefined) { + textBox.insertSpaces = attributes.insertSpaces; + } + + // === VISIBILITY AND DISPLAY === + if (attributes.visibility !== undefined) { + textBox.visibility = attributes.visibility; + } + + // === ALIGNMENT === + if (attributes.alignment !== undefined) { + textBox.textAlignment = this._getTextAlignment(attributes.alignment); + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + textBox.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + textBox.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + textBox.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + textBox.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + // Set border style if supported by PdfStandardBorder + textBox.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + textBox._dictionary.set('FontStyle', fontStyle); + textBox.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + if (attributes.fontStyle !== undefined) { + // Set font style (bold, italic, underline, strikethrough) + const fontStyle = this._getPdfFontStyle(attributes); + if (textBox.font) { + textBox.font.style = fontStyle; + } else { + const { PdfFontFamily } = require('@syncfusion/ej2-pdf'); + const fontFamily = PdfFontFamily.Helvetica; + const fontSize = attributes.fontSize || 12; + textBox.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + textBox.toolTip = attributes.tooltip; + } + + // === ADDITIONAL PROPERTIES === + if (attributes.customData !== undefined) { + // Custom data can be stored as user data if supported + // textBox.userData = attributes.customData; + } + + return textBox; + } catch (ex) { + console.error('[CreateTextField] Error:', ex.message); + return null; + } + } + + /** + * Creates a CheckBox form field with comprehensive property support. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + * @returns {PdfCheckBoxField|null} + */ + _createCheckBoxField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateCheckBoxField] Page object is undefined or null'); + return null; + } + + const { PdfCheckBoxField, PdfStandardFont } = require('@syncfusion/ej2-pdf'); + + const checkBox = new PdfCheckBoxField(fieldName, bounds, page); + + // === CHECKED STATE === + if (attributes.isChecked !== undefined) { + checkBox.checked = attributes.isChecked; + } + + // if (attributes.value !== undefined) { + // // For checkboxes, value is typically "Yes" or "No" + // checkBox.checked = (attributes.value === 'Yes' || attributes.value === true); + // } + + // === FIELD BEHAVIOR === + if (attributes.isReadonly !== undefined) { + checkBox.readOnly = attributes.isReadonly; + } + + if (attributes.isRequired !== undefined) { + checkBox.required = attributes.isRequired; + } + + // === VISIBILITY === + if (attributes.visibility !== undefined) { + checkBox.visibility = attributes.visibility; + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + checkBox.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + checkBox.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + checkBox.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + checkBox.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + checkBox.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + checkBox._dictionary.set('FontStyle', fontStyle); + checkBox.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + checkBox.toolTip = attributes.tooltip; + } + + return checkBox; + } catch (ex) { + console.error('[CreateCheckBoxField] Error:', ex.message); + return null; + } + } + + /** + * Creates a RadioButton form field with comprehensive property support. + * + * Properly creates PdfRadioButtonListItem objects for each option and adds them + * to the field. This ensures the radio buttons are correctly rendered in the PDF. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes (includes radiobuttonItem array or options) + * @param {Object} bounds - Field bounds + * @returns {PdfRadioButtonListField|null} + */ + _createRadioButtonField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateRadioButtonField] Page object is undefined or null'); + return null; + } + + const { PdfRadioButtonListField, PdfRadioButtonListItem, PdfStandardFont } = require('@syncfusion/ej2-pdf'); + + const radioButton = new PdfRadioButtonListField(page, fieldName); + + let selectedIndex = 0; + let isSelectedItem = false; + let isReadOnly = false; + let isRequired = false; + let itemCount = 0; + + // === OPTIONS/ITEMS === + // Handle both 'options' array format and 'radiobuttonItem' array format + const itemsArray = attributes.radiobuttonItem || attributes.options || []; + + if (Array.isArray(itemsArray) && itemsArray.length > 0) { + for (let i = 0; i < itemsArray.length; i++) { + const item = itemsArray[i]; + + // Get or create bounds for this item + let itemBounds = bounds; + if (item.bounds) { + itemBounds = this._parseBounds(item.bounds); + } else if (item.x !== undefined && item.y !== undefined) { + // If item has individual position + itemBounds = { + x: this.convertPixelToPoint(item.x), + y: this.convertPixelToPoint(item.y), + width: this.convertPixelToPoint(item.width || bounds.width || 15), + height: this.convertPixelToPoint(item.height || bounds.height || 15) + }; + } + + // Determine the radio button name/value + const radioButtonName = item.value || item.name || `${fieldName}_${i}`; + + // Create PdfRadioButtonListItem for this specific radio button + try { + const radioButtonItem = new PdfRadioButtonListItem(radioButtonName, itemBounds, page); + + // === ITEM PROPERTIES === + if (item.borderColor) { + radioButtonItem.borderColor = { + r: item.borderColor.r || 0, + g: item.borderColor.g || 0, + b: item.borderColor.b || 0 + }; + + // Check for transparent border + if (item.borderColor.r === 0 && item.borderColor.g === 0 && + item.borderColor.b === 0 && item.borderColor.a === 0) { + radioButtonItem.borderColor = { + r: item.borderColor.r, + g: item.borderColor.g, + b: item.borderColor.b, + isTransparent: true + }; + } + } + + if (item.thickness !== undefined) { + radioButtonItem.border.width = item.thickness; + } + + if (item.backgroundColor) { + radioButtonItem.backColor = { + r: item.backgroundColor.r || 0, + g: item.backgroundColor.g || 0, + b: item.backgroundColor.b || 0 + }; + + // Check for transparent background + if (item.backgroundColor.r === 0 && item.backgroundColor.g === 0 && + item.backgroundColor.b === 0 && item.backgroundColor.a === 0) { + radioButtonItem.backColor = { + r: item.backgroundColor.r, + g: item.backgroundColor.g, + b: item.backgroundColor.b, + isTransparent: true + }; + } + } + + if (item.visibility !== undefined) { + radioButtonItem.visibility = item.visibility; + } + + if (item.isReadonly !== undefined) { + isReadOnly = item.isReadonly || isReadOnly; + } + + if (item.isRequired !== undefined) { + isRequired = item.isRequired || isRequired; + } + + // === ADD ITEM TO FIELD === + radioButton.add(radioButtonItem); + itemCount++; + + // Track selected item + if (item.isSelected) { + selectedIndex = i; + isSelectedItem = true; + } + + } catch (itemError) { + console.error(`[CreateRadioButtonField] Error creating item ${i}:`, itemError.message); + // Continue with next item even if one fails + } + } + + } else { + console.warn('[CreateRadioButtonField] No radio button items provided in attributes'); + } + + // === FIELD BEHAVIOR === + radioButton.readOnly = isReadOnly; + radioButton.required = isRequired; + + if (attributes.isReadOnly !== undefined) { + radioButton.readOnly = attributes.isReadOnly; + } + + if (attributes.isRequired !== undefined) { + radioButton.required = attributes.isRequired; + } + + // === SELECTION === + if (isSelectedItem) { + radioButton.selectedIndex = selectedIndex; + } else if (attributes.selectedIndex !== undefined) { + radioButton.selectedIndex = attributes.selectedIndex; + } else if (attributes.value !== undefined) { + radioButton.selectedValue = attributes.value; + } + + // === VISIBILITY === + if (attributes.visibility !== undefined) { + radioButton.visibility = attributes.visibility; + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + radioButton.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + radioButton.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + radioButton.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + radioButton.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + radioButton.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + radioButton._dictionary.set('FontStyle', fontStyle); + radioButton.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + radioButton.toolTip = attributes.tooltip; + } + + // === CUSTOM DATA === + if (attributes.customData !== undefined) { + try { + const customData = JSON.stringify(attributes.customData); + if (radioButton._dictionary) { + radioButton._dictionary.set('CustomData', customData); + } + } catch (customDataError) { + console.warn('[CreateRadioButtonField] Could not set custom data:', customDataError.message); + } + } + + return radioButton; + } catch (ex) { + console.error('[CreateRadioButtonField] Error:', ex.message); + console.error('[CreateRadioButtonField] Stack:', ex.stack); + return null; + } + } + + /** + * Creates a DropDown (ComboBox) form field with comprehensive property support. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + * @returns {PdfComboBoxField|null} + */ + _createDropDownField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateDropDownField] Page object is undefined or null'); + return null; + } + + const { PdfComboBoxField, PdfStandardFont } = require('@syncfusion/ej2-pdf'); + + const comboBox = new PdfComboBoxField(page, fieldName, bounds); + + // === OPTIONS/ITEMS === + if (attributes.options && Array.isArray(attributes.options)) { + for (const option of attributes.options) { + const item = new PdfListFieldItem(option.itemName, + option.itemValue); + comboBox.addItem(item); + } + } + + // === VALUE AND SELECTION === + if (attributes.value !== undefined) { + comboBox.selectedValue = attributes.value; + } + + if (attributes.selectedIndex !== undefined) { + comboBox.selectedIndex = attributes.selectedIndex; + } + + // === FIELD BEHAVIOR === + if (attributes.isReadonly !== undefined) { + comboBox.readOnly = attributes.isReadonly; + } + + if (attributes.isRequired !== undefined) { + comboBox.required = attributes.isRequired; + } + + // === VISIBILITY === + if (attributes.visibility !== undefined) { + comboBox.visibility = attributes.visibility; + } + + // === ALIGNMENT === + if (attributes.alignment !== undefined) { + comboBox.textAlignment = this._getTextAlignment(attributes.alignment); + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + comboBox.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + comboBox.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + comboBox.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + comboBox.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + comboBox.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + comboBox._dictionary.set('FontStyle', fontStyle); + comboBox.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + comboBox.toolTip = attributes.tooltip; + } + + return comboBox; + } catch (ex) { + console.error('[CreateDropDownField] Error:', ex.message); + return null; + } + } + + /** + * Creates a ListBox form field with comprehensive property support. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + * @returns {PdfListBoxField|null} + */ + _createListBoxField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateListBoxField] Page object is undefined or null'); + return null; + } + + const { PdfListBoxField, PdfStandardFont } = require('@syncfusion/ej2-pdf'); + + // Default larger bounds for list box + const listBoxBounds = bounds.height < 40 + ? { ...bounds, height: 60 } + : bounds; + + const listBox = new PdfListBoxField(page, fieldName, listBoxBounds); + + // === OPTIONS/ITEMS === + if (attributes.options && Array.isArray(attributes.options)) { + for (const option of attributes.options) { + const item = new PdfListFieldItem(option.itemName, + option.itemValue); + listBox.addItem(item); + } + } + + // === SELECTION MODE === + if (attributes.multiSelect !== undefined) { + listBox.multiSelect = attributes.multiSelect; + } + if (listBox.itemsCount > 0) { + const count = attributes.selectedIndex.length; + if (attributes.selectedIndex !== undefined && Array.isArray(attributes.selectedIndex) && count > 0) { + if (count === 1) { + listBox.selectedIndex = attributes.selectedIndex[0]; + } else { + const selectedIndexes = []; + for (let j = 0; j < count; j++) { + selectedIndexes.push(attributes.selectedIndex[j]); + } + listBox.selectedIndex = selectedIndexes; + } + } else { + listBox.selectedIndex = 0; + } + } + + // === FIELD BEHAVIOR === + if (attributes.isReadonly !== undefined) { + listBox.readOnly = attributes.isReadonly; + } + + if (attributes.isRequired !== undefined) { + listBox.required = attributes.isRequired; + } + + // === VISIBILITY === + if (attributes.visibility !== undefined) { + listBox.visibility = attributes.visibility; + } + + // === ALIGNMENT === + if (attributes.alignment !== undefined) { + listBox.textAlignment = this._getTextAlignment(attributes.alignment); + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + listBox.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + listBox.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + listBox.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + listBox.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + listBox.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + listBox._dictionary.set('FontStyle', fontStyle); + listBox.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + listBox.toolTip = attributes.tooltip; + } + + return listBox; + } catch (ex) { + console.error('[CreateListBoxField] Error:', ex.message); + return null; + } + } + + /** + * Creates a Signature form field with comprehensive property support. + * Also renders the signature content as annotations based on signatureType. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + * @returns {PdfSignatureField|null} + */ + _createSignatureField(page, fieldName, attributes, bounds) { + try { + // Validate page parameter + if (!page) { + console.error('[CreateSignatureField] Page object is undefined or null'); + return null; + } + const { PdfSignatureField, PdfStandardFont } = require('@syncfusion/ej2-pdf'); + + let signatureField; + + if (!attributes.lineBound) { + signatureField = new PdfSignatureField(page, fieldName, bounds); + } + else { + bounds.x = attributes.lineBound.X + bounds.y = attributes.lineBound.Y + bounds.width = attributes.lineBound.Width + bounds.height = attributes.lineBound.height + signatureField = new PdfSignatureField(page, fieldName, bounds); + } + + if (attributes.formFieldAnnotationType === 'InitialField') { + signatureField._dictionary.set('InitialField', true); + } + // === FIELD BEHAVIOR === + if (attributes.isReadOnly !== undefined) { + signatureField.readOnly = attributes.isReadOnly; + } + + if (attributes.isRequired !== undefined) { + signatureField.required = attributes.isRequired; + } + + // === VISIBILITY === + if (attributes.visibility !== undefined) { + signatureField.visibility = attributes.visibility; + } + + // === COLORS === + if (attributes.backgroundColor !== undefined) { + const bgColor = this._parseColor(attributes.backgroundColor); + signatureField.backColor = bgColor; + } + + if (attributes.fontColor !== undefined) { + const fontColor = this._parseColor(attributes.fontColor); + signatureField.color = fontColor; + } + + if (attributes.borderColor !== undefined) { + const brdrColor = this._parseColor(attributes.borderColor); + signatureField.borderColor = brdrColor; + } + + // === BORDER PROPERTIES === + if (attributes.thickness !== undefined) { + signatureField.border.width = attributes.thickness; + } + + if (attributes.borderStyle !== undefined) { + signatureField.border.style = attributes.borderStyle; + } + + // === FONT PROPERTIES === + if (attributes.fontSize !== undefined || attributes.fontFamily !== undefined) { + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontSize = attributes.fontSize || 12; + const fontStyle = this._getPdfFontStyle(attributes); + signatureField._dictionary.set('FontStyle', fontStyle); + signatureField.font = new PdfStandardFont(fontFamily, this.convertPixelToPoint(fontSize), fontStyle); + } + + // === TOOLTIP === + if (attributes.tooltip !== undefined) { + signatureField.toolTip = attributes.tooltip; + } + + // === RENDER SIGNATURE CONTENT === + // Draw the actual signature value as an annotation on the page + if (attributes.value || attributes.signatureType) { + // Parse bounds if provided + let signBounds = this._parseBounds(attributes.signatureBound); + this._drawSignatureContent(page, fieldName, attributes, signBounds); + } + + return signatureField; + } catch (ex) { + console.error('[CreateSignatureField] Error:', ex.message); + return null; + } + } + + /** + * Draws the signature content (value) as an annotation on the page. + * Handles three signature types: Text, Image, and Path/Draw. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Signature field name + * @param {Object} attributes - Field attributes including signatureType and value + * @param {Object} bounds - Field bounds + */ + _drawSignatureContent(page, fieldName, attributes, bounds) { + try { + const signatureType = attributes.signatureType || 'Text'; + const value = attributes.value; + + if (!value) { + return; + } + + if (signatureType === 'Text') { + this._drawTextSignature(page, fieldName, attributes, bounds); + } else if (signatureType === 'Image') { + this._drawImageSignature(page, fieldName, attributes, bounds); + } else if (signatureType === 'Path' || signatureType === 'Draw') { + this._drawPathSignature(page, fieldName, attributes, bounds); + } else { + console.warn(`[DrawSignature] Unknown signature type: ${signatureType}`); + } + } catch (ex) { + console.error('[DrawSignature] Error drawing signature content:', ex.message); + } + } + + /** + * Draws a text-based signature as a FreeTextAnnotation. + * Renders the signature text with font properties at the specified bounds. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes + * @param {Object} bounds - Field bounds + */ + _drawTextSignature(page, fieldName, attributes, bounds) { + try { + const { PdfFreeTextAnnotation, PdfStandardFont, PdfTextAlignment, PdfAnnotationFlag } = require('@syncfusion/ej2-pdf'); + + const text = attributes.value; + const fontSize = attributes.fontSize || 12; + const fontFamily = this._getFontFamily(attributes.fontFamily || 'Helvetica'); + const fontStyle = this._getPdfFontStyle(attributes); + + // Create free text annotation + const annotation = new PdfFreeTextAnnotation({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }); + + // Set font + annotation.font = new PdfStandardFont( + fontFamily, + this.convertPixelToPoint(fontSize), + fontStyle + ); + + // Set text + annotation.text = text; + + // Set text alignment + annotation.textAlignment = PdfTextAlignment.center; + + // Set colors + if (attributes.fontColor) { + const fontColor = this._parseColor(attributes.fontColor); + annotation.color = fontColor; + } + + if (attributes.backgroundColor) { + const bgColor = this._parseColor(attributes.backgroundColor); + annotation.backColor = bgColor; + } + + // Set border + if (attributes.borderColor) { + const borderColor = this._parseColor(attributes.borderColor); + annotation.borderColor = borderColor; + } + annotation.border.width = attributes.thickness || 1; + + // Set visibility + annotation.flags = PdfAnnotationFlag.print; + if (attributes.visibility === 'hidden') { + annotation.flags = PdfAnnotationFlag.hidden; + } + + // Mark as signature annotation + annotation.setValues('AnnotationType', 'Signature'); + annotation._dictionary.set('T', fieldName); + + annotation.setAppearance(true); + page.annotations.add(annotation); + + } catch (ex) { + console.error('[DrawTextSignature] Error:', ex.message); + } + } + + /** + * Draws an image-based signature as a RubberStampAnnotation. + * Renders the signature image at the specified bounds. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes (value should be base64 image or data URL) + * @param {Object} bounds - Field bounds + */ + _drawImageSignature(page, fieldName, attributes, bounds) { + try { + const { PdfRubberStampAnnotation, PdfBitmap, PdfAnnotationFlag } = require('@syncfusion/ej2-pdf'); + + const imageValue = attributes.value; + if (!imageValue) { + console.warn('[DrawImageSignature] No image data provided'); + return; + } + + // Extract base64 from data URL if needed (format: "data:image/png;base64,...") + let imageData = imageValue; + if (typeof imageValue === 'string' && imageValue.includes(',')) { + imageData = imageValue.split(',')[1]; + } + + try { + // Create bitmap from base64 image data + const bitmap = new PdfBitmap(imageData); + + // Create rubber stamp annotation + const annotation = new PdfRubberStampAnnotation({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }); + + // Draw the image on the annotation's appearance + const imageRect = { + x: 0, + y: 0, + width: bounds.width, + height: bounds.height + }; + annotation.appearance.normal.graphics.drawImage(bitmap, imageRect); + + // Set visibility and flags + annotation.flags = PdfAnnotationFlag.print; + if (attributes.visibility === 'hidden') { + annotation.flags = PdfAnnotationFlag.hidden; + } + + // Mark as signature annotation + annotation._dictionary.set('T', fieldName); + + page.annotations.add(annotation); + + } catch (imageError) { + console.error('[DrawImageSignature] Error creating bitmap from image data:', imageError.message); + } + } catch (ex) { + console.error('[DrawImageSignature] Error:', ex.message); + } + } + + /** + * Draws a path/draw-based signature as an InkAnnotation. + * Renders the freehand drawing signature at the specified bounds. + * + * Based on EJ2 PDF Viewer's drawDesignerFieldPath implementation. + * Properly handles zoom, page rotation, min/max bounds, Y-coordinate normalization, and multiple strokes. + * + * @param {PdfPage} page - Target page + * @param {string} fieldName - Field name + * @param {Object} attributes - Field attributes including value (JSON stringified path points), zoomValue, signatureBound + * @param {Object} bounds - Field bounds + */ + _drawPathSignature(page, fieldName, attributes, bounds) { + try { + const { PdfInkAnnotation, PdfAnnotationFlag, PdfPath } = require('@syncfusion/ej2-pdf'); + + const pathValue = attributes.value; + if (!pathValue) { + console.warn('[DrawPathSignature] No path data provided'); + return; + } + + try { + // Parse path points from JSON or array format + let stampObjects = []; + if (typeof pathValue === 'string') { + stampObjects = JSON.parse(pathValue); + } else if (Array.isArray(pathValue)) { + stampObjects = pathValue; + } + + if (!Array.isArray(stampObjects) || stampObjects.length === 0) { + console.warn('[DrawPathSignature] Invalid or empty path data'); + return; + } + + // === CRITICAL FIXES === + // 1. Extract zoom value (used to scale input coordinates) + const zoomValue = attributes.zoomValue || 1; + + // 2. Get page rotation angle + const pageRotationAngle = page.rotation || 0; + + // 3. Get signature bounds (from attributes or fallback to bounds) + const signatureBounds = attributes.signatureBound || bounds; + const boundsObjects = { + X: signatureBounds.x || bounds.x, + Y: signatureBounds.y || bounds.y, + Width: signatureBounds.width || bounds.width, + Height: signatureBounds.height || bounds.height + }; + + // 4. Convert bounds from pixels to points, accounting for zoom + // let signBounds = { + // X: this.convertPixelToPoint(boundsObjects.X / zoomValue), + // Y: this.convertPixelToPoint(boundsObjects.Y / zoomValue), + // Width: this.convertPixelToPoint(boundsObjects.Width / zoomValue), + // Height: this.convertPixelToPoint(boundsObjects.Height / zoomValue) + // }; + + // 5. Adjust bounds for page rotation + signBounds = this._adjustBoundsForPageRotation( + signBounds, + page.size.height, + page.size.width, + pageRotationAngle + ); + + // 6. Calculate rotation angle for ink annotation + const rotationAngle = this._calculateInkRotationAngle(pageRotationAngle); + + // === STEP 1: Calculate min/max bounds of all path points === + let minimumX = -1; + let minimumY = -1; + let maximumX = -1; + let maximumY = -1; + + const drawingPath = new PdfPath(); + for (let p = 0; p < stampObjects.length; p++) { + const val = stampObjects[p]; + if (val.x !== undefined && val.y !== undefined) { + drawingPath.addLine({ x: val.x, y: val.y }, { x: 0, y: 0 }); + } + } + + // Get rotated path for min/max calculation + const rotatedPath = this._getRotatedPathForMinMax(drawingPath, rotationAngle); + + // Calculate min/max from rotated path + if (rotatedPath && rotatedPath.length > 0) { + for (let k = 0; k < rotatedPath.length; k++) { + const point = rotatedPath[k]; + const value0 = point[0]; + const value1 = point[1]; + + if (minimumX === -1) { + minimumX = value0; + minimumY = value1; + maximumX = value0; + maximumY = value1; + } else { + if (minimumX >= value0) minimumX = value0; + if (minimumY >= value1) minimumY = value1; + if (maximumX <= value0) maximumX = value0; + if (maximumY <= value1) maximumY = value1; + } + } + } + + // Prevent division by zero + if (minimumX === maximumX || minimumY === maximumY) { + console.warn('[DrawPathSignature] Invalid path bounds (min === max)'); + return; + } + + const left = signBounds.X; + const top = signBounds.Y; + const width = signBounds.Width; + const height = signBounds.Height; + + const newDifferenceX = (maximumX - minimumX) / width; + const newDifferenceY = (maximumY - minimumY) / height; + + // === STEP 2: Normalize first stroke === + let linePoints = []; + let isNewValues = 0; + const pageHeight = page.size.height; + + if (rotationAngle !== 0) { + // Rotated case: collect first stroke + for (let j = 0; j < stampObjects.length; j++) { + const value = stampObjects[j]; + const path = value.command ? value.command.toString() : ''; + + if (path === 'M' && j !== 0) { + isNewValues = j; + break; + } + + linePoints.push({ + x: parseFloat(value.x) || 0, + y: parseFloat(value.y) || 0 + }); + } + + const rotatedPoints = this._getRotatedPath(linePoints, rotationAngle); + linePoints = []; + + for (let z = 0; z < rotatedPoints.length; z++) { + const rotatedPoint = rotatedPoints[z]; + linePoints.push({ + x: (rotatedPoint[0] - minimumX) / newDifferenceX + left, + y: pageHeight - (rotatedPoint[1] - minimumY) / newDifferenceY - top + }); + } + } else { + // Non-rotated case: normalize first stroke + for (let k = 0; k < stampObjects.length; k++) { + const value = stampObjects[k]; + const path = value.command ? value.command.toString() : ''; + + if (path === 'M' && k !== 0) { + isNewValues = k; + break; + } + + const newX = (parseFloat(value.y) - minimumY) / newDifferenceY; + linePoints.push({ + x: (parseFloat(value.x) - minimumX) / newDifferenceX + left, + y: pageHeight - newX - top + }); + } + } + + // === STEP 3: Create ink annotation === + const annotation = new PdfInkAnnotation( + { + x: left, + y: top, + width: width, + height: height + }, + linePoints + ); + + // === STEP 4: Process additional strokes === + if (isNewValues > 0) { + if (rotationAngle !== 0) { + // Rotated case + const pathCollection = []; + + for (let t = isNewValues; t < stampObjects.length; t++) { + const value = stampObjects[t]; + const path = value.command ? value.command.toString() : ''; + + if (path === 'M' && t !== isNewValues) { + pathCollection.push([...linePoints]); + linePoints = []; + } + + linePoints.push({ + x: parseFloat(value.x) || 0, + y: parseFloat(value.y) || 0 + }); + } + + if (linePoints.length > 0) { + pathCollection.push(linePoints); + } + + for (let w = 0; w < pathCollection.length; w++) { + const pointsCollections = pathCollection[w]; + linePoints = []; + + if (pointsCollections.length > 0) { + const rotatedPoints = this._getRotatedPath(pointsCollections, rotationAngle); + + for (let z = 0; z < rotatedPoints.length; z++) { + const rotatedPoint = rotatedPoints[z]; + linePoints.push({ + x: (rotatedPoint[0] - minimumX) / newDifferenceX + left, + y: pageHeight - (rotatedPoint[1] - minimumY) / newDifferenceY - top + }); + } + + annotation.inkPointsCollection.push(linePoints); + } + } + } else { + // Non-rotated case + for (let r = isNewValues; r < stampObjects.length; r++) { + const value = stampObjects[r]; + const path = value.command ? value.command.toString() : ''; + + if (path === 'M' && r !== isNewValues) { + annotation.inkPointsCollection.push(linePoints); + linePoints = []; + } + + const newX = (parseFloat(value.y) - minimumY) / newDifferenceY; + linePoints.push({ + x: (parseFloat(value.x) - minimumX) / newDifferenceX + left, + y: pageHeight - newX - top + }); + } + + if (linePoints.length > 0) { + annotation.inkPointsCollection.push(linePoints); + } + } + } + + // === STEP 5: Set annotation properties === + annotation.bounds = { + x: signBounds.X, + y: signBounds.Y, + width: signBounds.Width, + height: signBounds.Height + }; + + annotation.border.width = 0; + annotation.flags = PdfAnnotationFlag.print; + if (attributes.visibility === 'hidden') { + annotation.flags = PdfAnnotationFlag.hidden; + } + + const strokeColor = this._parseColor(attributes.fontColor || 'black'); + annotation.color = strokeColor; + + annotation._dictionary.set('T', fieldName); + annotation.setValues('annotationSignature', 'annotationSignature'); + annotation.rotationAngle = Math.abs(this._getRotateAngle(pageRotationAngle)); + + annotation.setAppearance(true); + page.annotations.add(annotation); + + } catch (pathError) { + console.error('[DrawPathSignature] Error processing path data:', pathError.message); + console.error('[DrawPathSignature] Stack:', pathError.stack); + } + } catch (ex) { + console.error('[DrawPathSignature] Error:', ex.message); + console.error('[DrawPathSignature] Stack:', ex.stack); + } + } + + /** + * Adjusts bounds for page rotation. + * Accounts for 90°, 180°, 270° rotations. + * + * @param {Object} bounds - Original bounds {X, Y, Width, Height} + * @param {number} pageHeight - Page height in points + * @param {number} pageWidth - Page width in points + * @param {number} pageRotationAngle - Rotation angle (0, 90, 180, 270 or 0, 1, 2, 3) + * @returns {Object} Adjusted bounds + */ + _adjustBoundsForPageRotation(bounds, pageHeight, pageWidth, pageRotationAngle) { + try { + const normalizedAngle = ((pageRotationAngle || 0) % 360) / 90; + + if (normalizedAngle === 1 || normalizedAngle === 3) { + // 90° or 270° rotation + if (normalizedAngle === 1) { + // 90° clockwise + return { + X: pageHeight - (bounds.Y + bounds.Height), + Y: bounds.X, + Width: bounds.Height, + Height: bounds.Width + }; + } else { + // 270° clockwise + return { + X: bounds.Y, + Y: pageWidth - (bounds.X + bounds.Width), + Width: bounds.Height, + Height: bounds.Width + }; + } + } else if (normalizedAngle === 2) { + // 180° rotation + return { + X: pageWidth - (bounds.X + bounds.Width), + Y: pageHeight - (bounds.Y + bounds.Height), + Width: bounds.Width, + Height: bounds.Height + }; + } + + return bounds; // No rotation + } catch (ex) { + console.warn('[AdjustBoundsForPageRotation] Error:', ex.message); + return bounds; + } + } + + /** + * Calculates ink rotation angle from page rotation. + * + * @param {number} pageRotationAngle - Page rotation + * @returns {number} Rotation angle in degrees + */ + _calculateInkRotationAngle(pageRotationAngle) { + try { + const normalizedAngle = ((pageRotationAngle || 0) % 360); + + if (normalizedAngle === 90 || normalizedAngle === 1) return 90; + if (normalizedAngle === 180 || normalizedAngle === 2) return 180; + if (normalizedAngle === 270 || normalizedAngle === 3) return 270; + + return 0; + } catch (ex) { + console.warn('[CalculateInkRotationAngle] Error:', ex.message); + return 0; + } + } + + /** + * Gets rotated path points for min/max calculation. + * + * @param {PdfPath} drawingPath - Original path + * @param {number} rotationAngle - Rotation angle + * @returns {Array} Array of [x, y] coordinates + */ + _getRotatedPathForMinMax(drawingPath, rotationAngle) { + try { + if (!drawingPath || !drawingPath._points || drawingPath._points.length === 0) { + return []; + } + + if (rotationAngle === 0) { + return drawingPath._points; + } + + return this._rotatePoints(drawingPath._points, rotationAngle); + } catch (ex) { + console.warn('[GetRotatedPathForMinMax] Error:', ex.message); + return []; + } + } + + /** + * Gets rotated path from point array. + * + * @param {Array} points - Array of {x, y} points + * @param {number} rotationAngle - Rotation angle in degrees + * @returns {Array} Array of [x, y] rotated coordinates + */ + _getRotatedPath(points, rotationAngle) { + try { + if (!Array.isArray(points) || points.length === 0) { + return []; + } + + if (rotationAngle === 0) { + return points.map(p => [p.x || 0, p.y || 0]); + } + + const arrayPoints = points.map(p => [p.x || 0, p.y || 0]); + return this._rotatePoints(arrayPoints, rotationAngle); + } catch (ex) { + console.warn('[GetRotatedPath] Error:', ex.message); + return points.map(p => [p.x || 0, p.y || 0]); + } + } + + /** + * Rotates points by given angle around origin (0, 0). + * + * @param {Array} points - Array of [x, y] coordinates + * @param {number} angle - Rotation angle in degrees + * @returns {Array} Rotated points + */ + _rotatePoints(points, angle) { + try { + const radians = (angle * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + return points.map(point => { + const x = point[0]; + const y = point[1]; + return [ + x * cos - y * sin, + x * sin + y * cos + ]; + }); + } catch (ex) { + console.warn('[RotatePoints] Error:', ex.message); + return points; + } + } + + /** + * Gets rotation angle from page rotation value. + * Handles both degree values (0, 90, 180, 270) and index values (0, 1, 2, 3). + * + * @param {number} pageRotation - Page rotation + * @returns {number} Absolute rotation angle + */ + _getRotateAngle(pageRotation) { + try { + const normalizedAngle = ((pageRotation || 0) % 360); + + if (normalizedAngle === 90 || normalizedAngle === 1) return 90; + if (normalizedAngle === 180 || normalizedAngle === 2) return 180; + if (normalizedAngle === 270 || normalizedAngle === 3) return 270; + + return 0; + } catch (ex) { + console.warn('[GetRotateAngle] Error:', ex.message); + return 0; + } + } + + /** + * Applies page organizer actions to the PDF document. + * Handles page deletion, reordering, and insertion. + * + * @param {PdfDocument} document - Syncfusion PdfDocument instance + * @param {Object} pageOrganizerData - Page organizer action data + */ + async _applyPageOrganizerActions(document, pageOrganizerData) { + try { + + // Handle different page organizer action types + if (pageOrganizerData.action === 'delete') { + if (pageOrganizerData.originalPageIndex >= 0 && pageOrganizerData.originalPageIndex < document.pageCount) { + document.removePage(pageOrganizerData.originalPageIndex); + } + } else if (pageOrganizerData.action === 'reorder' || pageOrganizerData.action === 'rearrange') { + // Reorder page: Move page from sourceIndex to targetIndex + const sourceIndex = pageOrganizerData.sourcePageIndex !== undefined + ? pageOrganizerData.sourcePageIndex + : pageOrganizerData.originalPageIndex; + const targetIndex = pageOrganizerData.targetIndex; + + // Validate indices + if (sourceIndex < 0 || sourceIndex >= document.pageCount) { + console.warn(`[PageOrganizer] Invalid source index ${sourceIndex}. Document has ${document.pageCount} pages`); + return; + } + + if (targetIndex < 0 || targetIndex >= document.pageCount) { + console.warn(`[PageOrganizer] Invalid target index ${targetIndex}. Document has ${document.pageCount} pages`); + return; + } + + // If source and target are the same, no operation needed + if (sourceIndex === targetIndex) { + return; + } + + try { + // Build the complete page order array for reorderPages API + // This is cleaner than manual array manipulation + + // Step 1: Create array of all current page indices [0, 1, 2, ..., pageCount-1] + const pageIndices = Array.from({ length: document.pageCount }, (_, i) => i); + + // Step 2: Remove the page at sourceIndex + const removedPage = pageIndices[sourceIndex]; + pageIndices.splice(sourceIndex, 1); + + // Step 3: Insert at targetIndex + // Note: No adjustment needed because we've already removed the source page + pageIndices.splice(targetIndex, 0, removedPage); + + // Step 4: Call reorderPages API with the new page order + if (document.reorderPages && typeof document.reorderPages === 'function') { + try { + document.reorderPages(pageIndices); + } catch (reorderError) { + console.error('[PageOrganizer] reorderPages() failed:', reorderError.message); + throw reorderError; + } + } else { + console.error('[PageOrganizer] document.reorderPages() method not available'); + throw new Error('reorderPages API not available on PdfDocument'); + } + + } catch (reorderError) { + console.error('[PageOrganizer] Error during reorder operation:', reorderError.message); + console.error('[PageOrganizer] Stack:', reorderError.stack); + throw reorderError; + } + } else if (pageOrganizerData.action === 'rotate') { + const page = document.getPage(pageOrganizerData.originalPageIndex); + if (pageOrganizerData.rotateAngle === 90) { + page.rotation = PdfRotationAngle.angle90; + } else if (pageOrganizerData.rotateAngle === 180) { + page.rotation = PdfRotationAngle.angle180; + } else if (pageOrganizerData.rotateAngle === 270) { + page.rotation = PdfRotationAngle.angle270; + } + } else if (pageOrganizerData.action === 'insert') { + let pageSettings2 = new PdfPageSettings(); + pageSettings2.width = pageOrganizerData.pageSize.width; + pageSettings2.height = pageOrganizerData.pageSize.height; + document.addPage(pageOrganizerData.targetIndex, pageSettings2); + } else if (pageOrganizerData.action === 'copy') { + // Reorder pages + let options = new PdfPageImportOptions(); + // Sets the target page index to import + options.targetIndex = pageOrganizerData.targetIndex; + if (pageOrganizerData.rotateAngle === 90) { + options.rotation = PdfRotationAngle.angle90; + } else if (pageOrganizerData.rotateAngle === 180) { + options.rotation = PdfRotationAngle.angle180; + } else if (pageOrganizerData.rotateAngle === 270) { + options.rotation = PdfRotationAngle.angle270; + } + document.importPage(pageOrganizerData.sourceOriginalPageIndex, options); + } + } catch (ex) { + console.error('[PageOrganizer] Error applying page organizer actions:', ex.message); + throw ex; + } + } + + /** + * Process save request asynchronously with Redis action cleanup. + * + * Orchestrates the full merge workflow: + * 1. Load master PDF from storage (via controller or passed in request) + * 2. Retrieve pending operations from Redis + * 3. Replay operations against master document via replayOperationsAndUpdateDocument() + * 4. Persist updated PDF back to storage + * 5. Clean up Redis (partial or full based on partialSave flag) + * 6. Broadcast save completion notification to all clients in room + * + * Save Behavior: + * - PartialSave == true: + * * Remove only the first N actions (where N = SaveThreshold count) + * * Retain remaining unprocessed actions in Redis for next save cycle + * * Supports incremental save workflow + * + * - PartialSave == false: + * * Remove ALL pending actions associated with the room from Redis + * * Used when document is finalized and no further collaboration needed + * * Clears complete action history after final save + * + * @param {Object} request - Save request with roomName, actions, partialSave flag + */ + async processSaveRequestAsync(request) { + try { + // debugger; // Breakpoint: Entry point of save request + if (!request) { + return; + } + + if (!request.roomName) { + return; + } + + // Step 1: Retrieve all pending operations for this room + const allOperations = request.actions; + + // Step 2: Load master PDF from storage service + let masterPdfBase64 = ''; + if (this.storageService) { + const pdfResult = await this.storageService.getPdfAsync( + request.fileName || 'document.pdf', + request.roomName + ); + + if (pdfResult.success) { + masterPdfBase64 = pdfResult.content; + } else { + throw new Error( + `Failed to retrieve PDF from storage: ${pdfResult.error}` + ); + } + } + + if (!masterPdfBase64) { + throw new Error( + 'Master PDF not provided in request and storage service unavailable' + ); + } + + // Step 3: Replay operations and generate updated PDF + let updatedPdfBlob = null; + + if (allOperations && allOperations.length > 0) { + try { + updatedPdfBlob = await this.replayOperationsAndUpdateDocument( + masterPdfBase64, + allOperations + ); + + if (!updatedPdfBlob || updatedPdfBlob.size === 0) { + throw new Error('Operation replay returned empty PDF'); + } + + } catch (replayError) { + console.error('[ProcessSave] Operation replay failed:', replayError.message); + console.error('[ProcessSave] Stack:', replayError.stack); + throw replayError; + } + } else { + console.warn('[ProcessSave] No operations to replay, using master PDF as-is'); + + // If no operations, use master PDF as-is + const pdfBuffer = Buffer.from(masterPdfBase64, 'base64'); + updatedPdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); + } + + // Step 4: Persist updated PDF + if (!updatedPdfBlob || updatedPdfBlob.size === 0) { + throw new Error('Updated PDF is empty or invalid before persistence'); + } + + if (this.storageService) { + try { + // Convert Blob to Buffer for storage + const arrayBuffer = await updatedPdfBlob.arrayBuffer(); + const pdfBuffer = Buffer.from(arrayBuffer); + + const saveResult = await this.storageService.storePdfAsync( + pdfBuffer, + request.fileName || 'document.pdf', + request.roomName + ); + + if (!saveResult.success) { + throw new Error(`Failed to save PDF: ${saveResult.error}`); + } + + } catch (storageError) { + console.error('[ProcessSave] Storage persistence failed:', storageError.message); + throw storageError; + } + } else { + console.warn('[ProcessSave] No storage service available, skipping persistence'); + } + + request.updatedPdfSize = updatedPdfBlob.size; + + // Step 5: Clean up Redis based on save mode + const actionCount = (request.actions && request.actions.length) || 0; + + // if (request.partialSave) { + // console.log( + // `[ProcessSave] Partial Save: Removing ${actionCount} actions ` + + // `from room '${request.roomName}' (threshold-based cleanup)` + // ); + // } else { + // console.log( + // `[ProcessSave] Full Save: Removing all ${actionCount} actions ` + + // `from room '${request.roomName}' (complete cleanup)` + // ); + // } + } catch (ex) { + console.error( + `Error in processSaveRequestAsync for room '${request?.roomName}': ` + + `${ex.message}\n${ex.stack}` + ); + throw ex; + } + } +} + +module.exports = PdfViewerCollaborationAdapter; diff --git a/Collaborative Editing/Server/controllers/collaborative-editing-controller.js b/Collaborative Editing/Server/controllers/collaborative-editing-controller.js new file mode 100644 index 0000000..66db1dc --- /dev/null +++ b/Collaborative Editing/Server/controllers/collaborative-editing-controller.js @@ -0,0 +1,275 @@ +/** + * Collaborative Editing Controller for PDF Viewer. + * Implements version-based state management matching Document Editor pattern. + * + * Key characteristics: + * - No merge logic (removed XfdfMergeHelper) + * - Unified request/response format + * - Broadcast original request to other clients + * - Last-write-wins for page organizer, snapshots for annotations + */ + +function registerRoutes(app, actionService, adapter, transport) { + /** + * Import/join a collaboration room - returns current state snapshots. + * + * Returns the latest version of: + * - Annotations (XFDF snapshot) + * - Form fields (merged from all formField-type actions) + * - Page organizer (latest single state) + */ + app.post('/api/CollaborativeEditing/ImportFile', async (req, res) => { + try { + const { roomName, fileName, userName, connectionId, currentUser } = req.body; + + if (!roomName) { + return res.status(400).json({ error: 'Room name is required' }); + } + + // Get all pending operations for this room + const allActions = await actionService.getPendingOperations(roomName, 0, -1); + + // Return raw operations - client handles filtering/reconstruction + const operations = (allActions || []).map((x) => + adapter.mapGenericToControlAction(x) + ); + + return res.json({ roomName, version: allActions.length, operations }); + } catch (e) { + console.error('[ImportFile] Error:', e.message); + return res.status(500).json({ + error: 'Failed to import file', + details: e.message + }); + } + }); + + /** + * Unified API for all collaborative update actions. + * + * Handles all action types: + * - "annotation": XFDF-based annotation changes + * - "formField": Single form field value update + * - "formFieldAction": Form field creation/update/deletion + * - "pageOrganizer": Page order, rotation, etc. + * + * Flow: + * 1. Validate RoomName and Type + * 2. Extract and validate type-specific data + * 3. Convert to CollaborationAction via adapter + * 4. Store in Redis + * 5. Broadcast clean request with strongly-typed data to other clients + */ + app.post('/api/CollaborativeEditing/UpdateAction', async (req, res) => { + try { + const request = req.body; + + if (!request.roomName) { + return res.status(400).json({ error: 'RoomName is required' }); + } + + if (!request.type) { + return res.status(400).json({ error: 'Type is required' }); + } + + let data = null; + let actionDescription = ''; + const timestamp = new Date().toISOString(); + + // Validate and extract type-specific data + switch (request.type) { + case 'annotation': { + const annotationData = request.data; + if (!annotationData || !annotationData.xfdfData) { + return res.status(400).json({ + error: 'Data.xfdfData is required for annotation type' + }); + } + data = annotationData; + actionDescription = 'Annotation updated'; + break; + } + + case 'formField': { + const formFieldData = request.data; + if (!formFieldData || !formFieldData.jsonData) { + return res.status(400).json({ + error: 'Data.jsonData is required for formField type' + }); + } + data = formFieldData; + actionDescription = 'Form field updated'; + break; + } + + case 'formFieldAction': { + const formFieldActionData = request.data; + if (!formFieldActionData || !formFieldActionData.changes) { + return res.status(400).json({ + error: 'Data.changes is required for formFieldAction type' + }); + } + + // Validate changes JSON format + let changesObj; + try { + changesObj = JSON.parse(formFieldActionData.changes); + if (!changesObj) { + return res.status(400).json({ + error: 'Invalid changes JSON format' + }); + } + } catch (ex) { + return res.status(400).json({ + error: 'Failed to parse changes JSON', + details: ex.message + }); + } + + data = formFieldActionData; + actionDescription = 'Form field action updated'; + break; + } + + case 'pageOrganizer': { + if (!request.data) { + return res.status(400).json({ + error: 'Data is required for pageOrganizer type' + }); + } + data = request.data; + actionDescription = 'Page organizer updated'; + break; + } + + default: + return res.status(400).json({ + error: `Invalid action type: ${request.type}` + }); + } + + // Unified logging + let action = request.data.action; + if (request.type === 'pageOrganizer' && typeof request.data === 'string') { + const parsedData = JSON.parse(request.data); + action = parsedData.map(item => item.action).join(', '); + } + console.log(`ConnectionId: ${request.connectionId} | RoomName: ${request.roomName} | Data Type: ${request.type} | Action: ${action}`); + + // Convert to CollaborationAction + const collaborationAction = adapter.mapControlToGenericAction(request); + + // Store in Redis + await actionService.addOperation(collaborationAction, adapter); + + // Get all pending operations for this room + const allActions = await actionService.getPendingOperations(request.roomName, 0, -1); + + // Broadcast: Reconstruct a clean request with strongly-typed data + // This ensures the transport layer receives properly formatted data that serializes correctly + const broadcastRequest = { + roomName: request.roomName, + connectionId: request.connectionId, + userName: request.userName, + type: request.type, + currentVersion: request.currentVersion, + data: data + }; + + // Broadcast to other clients in room + // Clients filter via connectionId to ignore their own updates + if (transport && typeof transport.broadcastToRoom === 'function') { + try { + await transport.broadcastToRoom( + request.roomName, + { + event: 'action', + data: broadcastRequest + } + ); + } catch (broadcastError) { + console.error('[UpdateAction] Broadcast error:', broadcastError.message); + // Continue even if broadcast fails - action is already stored + } + } + + return res.json({ + success: true, + message: actionDescription, + data: data + }); + } catch (e) { + console.error('[UpdateAction] Error:', e.message, e.stack); + return res.status(500).json({ + error: 'Failed to update action', + details: e.message + }); + } + }); +} + +/** + * Register PDF Document Management routes. + * + * Handles PDF storage and retrieval for collaboration rooms. + * Provides fallback to default PDF when room-specific document is unavailable. + */ +function registerPdfDocumentRoutes(app, pdfStorageService) { + /** + * Retrieve a stored PDF document as Base64 encoded content. + * + * Used by clients to: + * - Download finalized collaborative documents + * - Verify document state before uploading updates + * - Archive document versions + * + * Query Parameters: + * - fileName (optional): PDF file name. Defaults to "document.pdf" + * - roomName (optional): Collaboration room identifier. Defaults to "default" + * + * Response: + * { + * "success": true, + * "fileName": "document.pdf", + * "roomName": "room123", + * "content": "JVBERi0xLjQKJeLjz9M...", // Base64 encoded PDF + * "contentLength": 45234, + * "isDefault": false + * } + * + * If document not found in room storage, returns default PDF with isDefault: true + */ + app.get('/api/CollaborativeEditing/GetPDFDocument', async (req, res) => { + try { + const { fileName, roomName } = req.query; + + if (!pdfStorageService) { + return res.status(500).json({ + success: false, + error: 'PDF Storage Service not initialized' + }); + } + + // Get PDF (with fallback to default) + const result = await pdfStorageService.getPdfAsync(fileName, roomName); + + if (!result.success) { + return res.status(404).json(result); + } + + return res.json(result); + } catch (error) { + console.error('[GetPDFDocument] Error:', error.message); + return res.status(500).json({ + success: false, + error: 'Failed to retrieve PDF', + details: error.message + }); + } + }); +} + +module.exports = { + registerRoutes, + registerPdfDocumentRoutes +}; \ No newline at end of file diff --git a/Collaborative Editing/Server/launch.settings.json b/Collaborative Editing/Server/launch.settings.json new file mode 100644 index 0000000..5dcdd8d --- /dev/null +++ b/Collaborative Editing/Server/launch.settings.json @@ -0,0 +1,50 @@ +{ + "profiles": { + "CollaborationServer": { + "commandName": "Node", + "program": "${workspaceFolder}/server.js", + "args": [], + "env": { + "NODE_ENV": "development", + "PORT": "8081" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "restart": true, + "sourceMapPathOverrides": { + "webpack:///*": "${workspaceFolder}/*" + } + }, + "CollaborationServer-Debug": { + "commandName": "Node", + "program": "${workspaceFolder}/server.js", + "args": [], + "env": { + "NODE_ENV": "development", + "PORT": "8081", + "DEBUG": "pdf-collaborator:*" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "restart": true, + "stopOnEntry": true, + "sourceMapPathOverrides": { + "webpack:///*": "${workspaceFolder}/*" + } + }, + "CollaborationServer-Watch": { + "commandName": "Node", + "program": "${workspaceFolder}/node_modules/nodemon/bin/nodemon.js", + "args": [ + "${workspaceFolder}/server.js" + ], + "env": { + "NODE_ENV": "development", + "PORT": "8081" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "restart": true + } + } +} diff --git a/Collaborative Editing/Server/package.json b/Collaborative Editing/Server/package.json new file mode 100644 index 0000000..0773982 --- /dev/null +++ b/Collaborative Editing/Server/package.json @@ -0,0 +1,31 @@ +{ + "name": "node-collaboration-sample", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js", + "serve": "node server.js", + "dev": "nodemon server.js", + "debug": "node --inspect server.js", + "debug-brk": "node --inspect-brk server.js" + }, + "keywords": [ + "pdf", + "collaboration", + "real-time", + "editing", + "syncfusion", + "websocket", + "redis" + ], + "author": "Syncfusion", + "license": "ISC", + "description": "PDF Viewer Collaboration Server - Real-time collaborative editing for PDF documents using Node.js", + "dependencies": { + "@syncfusion/ej2-collaborator-server": "*", + "@syncfusion/ej2-pdf": "*", + "@xmldom/xmldom": "^0.9.12", + "cors": "^2.8.6", + "open": "^11.0.4" + } +} \ No newline at end of file diff --git a/Collaborative Editing/Server/server-output.log b/Collaborative Editing/Server/server-output.log new file mode 100644 index 0000000..65b9ffc Binary files /dev/null and b/Collaborative Editing/Server/server-output.log differ diff --git a/Collaborative Editing/Server/server.js b/Collaborative Editing/Server/server.js new file mode 100644 index 0000000..b513458 --- /dev/null +++ b/Collaborative Editing/Server/server.js @@ -0,0 +1,84 @@ +const { + CollaborationServer +} = require('@syncfusion/ej2-collaborator-server'); +const PdfViewerCollaborationAdapter = + require('./adapters/PdfViewerCollaborationAdapter'); +const { + registerRoutes, + registerPdfDocumentRoutes +} = require('./controllers/collaborative-editing-controller'); +const PdfStorageService = require('./services/pdf-storage-service'); + +const cors = require('cors'); + +// Initialize PDF Storage Service +const pdfStorageService = new PdfStorageService(); + +const adapter = new PdfViewerCollaborationAdapter({ + storageService: pdfStorageService +}); + +const server = + new CollaborationServer({ + + port: 8081, + + redis: { + + host: + 'pdfviewercache.redis.cache.windows.net', + + port: 6380, + username: 'default', + + password: + 'K9dp2I3jyTjFl1KPLkZlj5ekQ3zgER6embOhWquvZJM=', + + tls: {} + }, + adapter, + saveThreshold: 2 + + }); + +// Inject transport into adapter for save notifications +adapter.transport = server.getTransport ? server.getTransport() : null; + + +server.app.use(cors()); + +// Register collaborative editing routes +registerRoutes( + server.app, + server.actionService, + adapter, + server +); + +// Register PDF document management routes +registerPdfDocumentRoutes( + server.app, + pdfStorageService +); + +// Health check endpoint +server.app.get('/api/test', (req, res) => { + res.json({ status: 'PDF Viewer Collaboration Server Running' }); +}); + +server.start(); + +// Graceful shutdown: Close Puppeteer browser on process termination +const gracefulShutdown = async () => { + try { + // Close Puppeteer browser + await adapter.closeBrowser(); + } catch (ex) { + console.error('[Shutdown] Error during cleanup:', ex.message); + } + process.exit(0); +}; + +process.on('SIGINT', gracefulShutdown); +process.on('SIGTERM', gracefulShutdown); + diff --git a/Collaborative Editing/Server/services/azure-pdf-storage-service.js b/Collaborative Editing/Server/services/azure-pdf-storage-service.js new file mode 100644 index 0000000..4e49c12 --- /dev/null +++ b/Collaborative Editing/Server/services/azure-pdf-storage-service.js @@ -0,0 +1,266 @@ +/** + * Azure Blob Storage Service for PDF Documents + * + * Manages PDF document storage and retrieval using Azure Blob Storage. + * Provides seamless integration with collaborative editing rooms. + * + * Features: + * - Store and retrieve PDFs from Azure Blob Storage + * - Per-room blob container organization + * - Automatic fallback to default PDF + * - Convert PDFs to/from Base64 for client transmission + * + * Required Environment Variables: + * - AZURE_STORAGE_CONNECTION_STRING: Azure Storage connection string + * - AZURE_STORAGE_CONTAINER_NAME: Base container name (rooms stored as subfolders) + */ + +const { BlobServiceClient } = require('@azure/storage-blob'); +const fs = require('fs'); +const path = require('path'); + +class AzurePdfStorageService { + constructor(options = {}) { + // Azure configuration + this.connectionString = options.connectionString || + process.env.AZURE_STORAGE_CONNECTION_STRING; + this.containerName = options.containerName || + (process.env.AZURE_STORAGE_CONTAINER_NAME || 'pdfs'); + + // Local fallback for default PDF + this.defaultPdfPath = options.defaultPdfPath || + path.join(__dirname, '..', 'wwwroot', 'PDF', 'pdf-succinctly.pdf'); + + if (!this.connectionString) { + throw new Error( + 'Azure Storage connection string not provided. ' + + 'Set AZURE_STORAGE_CONNECTION_STRING environment variable or pass in options.' + ); + } + + // Initialize Azure Blob Service Client + this.blobServiceClient = BlobServiceClient.fromConnectionString( + this.connectionString + ); + this.containerClient = this.blobServiceClient.getContainerClient( + this.containerName + ); + + } + + /** + * Get PDF document as Base64 encoded string from Azure Blob Storage + * + * @param {string} fileName - PDF file name (default: "document.pdf") + * @param {string} roomName - Collaboration room identifier (default: "default") + * @returns {Promise} { success, fileName, roomName, content, contentLength, isDefault } + */ + async getPdfAsync(fileName = 'document.pdf', roomName = 'default') { + try { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + + + + // Construct blob path: roomName/fileName + const blobPath = `${resolvedRoomName}/${resolvedFileName}`; + const blockBlobClient = this.containerClient.getBlockBlobClient(blobPath); + + try { + // Attempt to download from Azure + const downloadBlockBlobResponse = await blockBlobClient.download(); + const buffer = await this._streamToBuffer( + downloadBlockBlobResponse.readableStreamBody + ); + const base64Content = buffer.toString('base64'); + + + return { + success: true, + fileName: resolvedFileName, + roomName: resolvedRoomName, + content: base64Content, + contentLength: buffer.length, + isDefault: false + }; + } catch (azureError) { + // Blob not found in Azure - try local fallback + if (azureError.code === 'BlobNotFound' || azureError.code === 'ContainerNotFound') { + + + if (fs.existsSync(this.defaultPdfPath)) { + const buffer = fs.readFileSync(this.defaultPdfPath); + const base64Content = buffer.toString('base64'); + + + + return { + success: true, + fileName: path.basename(this.defaultPdfPath), + roomName: resolvedRoomName, + content: base64Content, + contentLength: buffer.length, + isDefault: true, + message: + 'PDF document not found in Azure - serving default document' + }; + } + + // No fallback available + return { + success: false, + error: `PDF document not found in Azure and default fallback unavailable`, + fileName: resolvedFileName, + roomName: resolvedRoomName + }; + } + + // Other Azure error + throw azureError; + } + } catch (error) { + console.error('[AzurePdfStorageService.getPdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to retrieve PDF: ${error.message}`, + details: error.toString() + }; + } + } + + /** + * Store a PDF document to Azure Blob Storage for a specific room + * + * @param {Buffer} pdfBuffer - PDF file content as Buffer or Blob + * @param {string} fileName - PDF file name (default: "document.pdf") + * @param {string} roomName - Collaboration room identifier (default: "default") + * @returns {Promise} { success, blobPath, size } + */ + async storePdfAsync(pdfBuffer, fileName = 'document.pdf', roomName = 'default') { + try { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + + + + // Convert Blob to Buffer if needed + let bufferData = pdfBuffer; + if (pdfBuffer instanceof Blob || (typeof pdfBuffer === 'object' && pdfBuffer.arrayBuffer)) { + const arrayBuffer = await pdfBuffer.arrayBuffer(); + bufferData = Buffer.from(arrayBuffer); + } + + // Construct blob path + const blobPath = `${resolvedRoomName}/${resolvedFileName}`; + const blockBlobClient = this.containerClient.getBlockBlobClient(blobPath); + + // Upload to Azure + const uploadBlobResponse = await blockBlobClient.upload( + bufferData, + bufferData.length + ); + + + + return { + success: true, + blobPath, + size: bufferData.length, + requestId: uploadBlobResponse.requestId + }; + } catch (error) { + console.error('[AzurePdfStorageService.storePdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to store PDF in Azure: ${error.message}`, + details: error.toString() + }; + } + } + + /** + * Delete a PDF document from Azure Blob Storage + * + * @param {string} fileName - PDF file name + * @param {string} roomName - Collaboration room identifier + * @returns {Promise} { success, blobPath } + */ + async deletePdfAsync(fileName = 'document.pdf', roomName = 'default') { + try { + const blobPath = `${roomName}/${fileName}`; + const blockBlobClient = this.containerClient.getBlockBlobClient(blobPath); + + await blockBlobClient.delete(); + + + return { + success: true, + blobPath + }; + } catch (error) { + console.error('[AzurePdfStorageService.deletePdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to delete PDF: ${error.message}` + }; + } + } + + /** + * List all PDFs in a room + * + * @param {string} roomName - Collaboration room identifier + * @returns {Promise} { success, files: [{name, size, lastModified}] } + */ + async listPdfsInRoomAsync(roomName = 'default') { + try { + const prefix = `${roomName}/`; + const files = []; + + for await (const blob of this.containerClient.listBlobsFlat({ prefix })) { + // Extract just the filename (without roomName prefix) + const fileName = blob.name.substring(prefix.length); + if (fileName) { + // Exclude blobs that are just the room folder + files.push({ + name: fileName, + size: blob.properties.contentLength, + lastModified: blob.properties.lastModified + }); + } + } + + + return { + success: true, + roomName, + files + }; + } catch (error) { + console.error('[AzurePdfStorageService.listPdfsInRoomAsync] Error:', error.message); + return { + success: false, + error: `Failed to list PDFs: ${error.message}` + }; + } + } + + /** + * Convert readable stream to Buffer + * @private + */ + async _streamToBuffer(readableStream) { + return new Promise((resolve, reject) => { + const chunks = []; + readableStream.on('data', (data) => { + chunks.push(data instanceof Buffer ? data : Buffer.from(data)); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + readableStream.on('error', reject); + }); + } +} + +module.exports = AzurePdfStorageService; diff --git a/Collaborative Editing/Server/services/pdf-storage-service.js b/Collaborative Editing/Server/services/pdf-storage-service.js new file mode 100644 index 0000000..95254ce --- /dev/null +++ b/Collaborative Editing/Server/services/pdf-storage-service.js @@ -0,0 +1,254 @@ +/** + * PDF Storage Service for Node.js Collaboration Server + * + * Manages PDF document storage and retrieval for collaborative editing rooms. + * + * Features: + * - Store and retrieve PDFs by roomName and fileName + * - Provide fallback to default PDF when document not found + * - Convert PDFs to Base64 for client transmission + * - Handle file system operations safely + */ + +const fs = require('fs'); +const path = require('path'); + +class PdfStorageService { + constructor(baseStoragePath = null, defaultPdfPath = null) { + /** + * Base storage path for PDF files + * Default: project root/storage/pdfs + */ + this.baseStoragePath = baseStoragePath || + path.join(__dirname, '..', 'storage', 'pdfs'); + + /** + * Default PDF path for fallback + * Default: wwwroot/PDF/pdf-succinctly.pdf + */ + this.defaultPdfPath = defaultPdfPath || + path.join(__dirname, '..', 'wwwroot', 'PDF', 'pdf-succinctly.pdf'); + + // Ensure base storage directory exists + this._ensureDirectoryExists(this.baseStoragePath); + + } + + /** + * Get PDF document as Base64 encoded string + * + * @param {string} fileName - PDF file name (default: "document.pdf") + * @param {string} roomName - Collaboration room identifier (default: "default") + * @returns {Promise} { success, fileName, roomName, content, contentLength, isDefault } + */ + async getPdfAsync(fileName = 'document.pdf', roomName = 'default') { + try { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + + + // Construct room-specific storage path + const roomStoragePath = path.join(this.baseStoragePath, resolvedRoomName); + const pdfPath = path.join(roomStoragePath, resolvedFileName); + + // Check if room-specific PDF exists + if (this._fileExists(pdfPath)) { + const buffer = await this._readFileAsync(pdfPath); + const base64Content = buffer.toString('base64'); + + return { + success: true, + fileName: resolvedFileName, + roomName: resolvedRoomName, + content: base64Content, + contentLength: buffer.length, + isDefault: false + }; + } + + + + if (this._fileExists(this.defaultPdfPath)) { + const buffer = await this._readFileAsync(this.defaultPdfPath); + const base64Content = buffer.toString('base64'); + + return { + success: true, + fileName: path.basename(this.defaultPdfPath), + roomName: resolvedRoomName, + content: base64Content, + contentLength: buffer.length, + isDefault: true, + message: 'PDF document not found in room storage - serving default document' + }; + } + + // Neither room-specific nor default PDF found + console.error(`[PdfStorageService] PDF not found - room: ${pdfPath}, default: ${this.defaultPdfPath}`); + + return { + success: false, + error: `PDF document not found: ${resolvedFileName}`, + fileName: resolvedFileName, + roomName: resolvedRoomName, + message: 'Document not in room storage and default fallback not available', + searchedPaths: [pdfPath, this.defaultPdfPath] + }; + } catch (error) { + console.error('[PdfStorageService.getPdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to retrieve PDF: ${error.message}`, + details: error.toString() + }; + } + } + + /** + * Store a PDF document for a specific room + * + * @param {Buffer} pdfBuffer - PDF file content as Buffer + * @param {string} fileName - PDF file name (default: "document.pdf") + * @param {string} roomName - Collaboration room identifier (default: "default") + * @returns {Promise} { success, filePath, size } + */ + async storePdfAsync(pdfBuffer, fileName = 'document.pdf', roomName = 'default') { + try { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + + + // Create room directory + const roomStoragePath = path.join(this.baseStoragePath, resolvedRoomName); + this._ensureDirectoryExists(roomStoragePath); + + // Write PDF file + const filePath = path.join(roomStoragePath, resolvedFileName); + await this._writeFileAsync(filePath, pdfBuffer); + + + return { + success: true, + filePath, + size: pdfBuffer.length + }; + } catch (error) { + console.error('[PdfStorageService.storePdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to store PDF: ${error.message}`, + details: error.toString() + }; + } + } + + /** + * Check if a PDF exists for a specific room + * + * @param {string} fileName - PDF file name + * @param {string} roomName - Collaboration room identifier + * @returns {boolean} true if room-specific PDF exists + */ + pdfExistsForRoom(fileName = 'document.pdf', roomName = 'default') { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + const roomStoragePath = path.join(this.baseStoragePath, resolvedRoomName); + const pdfPath = path.join(roomStoragePath, resolvedFileName); + return this._fileExists(pdfPath); + } + + /** + * Delete a PDF document for a specific room + * + * @param {string} fileName - PDF file name + * @param {string} roomName - Collaboration room identifier + * @returns {Promise} { success, message } + */ + async deletePdfAsync(fileName = 'document.pdf', roomName = 'default') { + try { + const resolvedFileName = fileName || 'document.pdf'; + const resolvedRoomName = roomName || 'default'; + + const roomStoragePath = path.join(this.baseStoragePath, resolvedRoomName); + const filePath = path.join(roomStoragePath, resolvedFileName); + + if (!this._fileExists(filePath)) { + return { + success: false, + error: `PDF not found: ${filePath}` + }; + } + + await this._deleteFileAsync(filePath); + + return { + success: true, + message: `PDF deleted: ${resolvedRoomName}/${resolvedFileName}` + }; + } catch (error) { + console.error('[PdfStorageService.deletePdfAsync] Error:', error.message); + return { + success: false, + error: `Failed to delete PDF: ${error.message}` + }; + } + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Ensure directory exists, create if needed + */ + _ensureDirectoryExists(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + } + + /** + * Check if file exists + */ + _fileExists(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } + + /** + * Read file asynchronously + */ + _readFileAsync(filePath) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + } + + /** + * Write file asynchronously + */ + _writeFileAsync(filePath, data) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, data, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + /** + * Delete file asynchronously + */ + _deleteFileAsync(filePath) { + return new Promise((resolve, reject) => { + fs.unlink(filePath, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } +} + +module.exports = PdfStorageService; diff --git a/Collaborative Editing/Server/wwwroot/PDF/pdf-succinctly.pdf b/Collaborative Editing/Server/wwwroot/PDF/pdf-succinctly.pdf new file mode 100644 index 0000000..ad91de2 Binary files /dev/null and b/Collaborative Editing/Server/wwwroot/PDF/pdf-succinctly.pdf differ diff --git a/Semantic Text Comparison/Highlight differences in UI/index.html b/Semantic Text Comparison/Highlight differences in UI/index.html new file mode 100644 index 0000000..ed86a28 --- /dev/null +++ b/Semantic Text Comparison/Highlight differences in UI/index.html @@ -0,0 +1,29 @@ + + + + + EJ2 PDF Viewer + + + + + + + + + + + +
+
+
+ + + + + \ No newline at end of file diff --git a/Semantic Text Comparison/Highlight differences in UI/index.js b/Semantic Text Comparison/Highlight differences in UI/index.js new file mode 100644 index 0000000..967744b --- /dev/null +++ b/Semantic Text Comparison/Highlight differences in UI/index.js @@ -0,0 +1,8 @@ +var pdfComparer = new ej.pdfviewer.PdfComparer({ + originalDocumentPath: 'https://cdn.syncfusion.com/content/pdf/original-document.pdf', + modifiedDocumentPath: 'https://cdn.syncfusion.com/content/pdf/modified-document.pdf', + resourceUrl: 'https://cdn.syncfusion.com/ej2/34.2.2/dist/ej2-pdfviewer-lib' +}); + +// PDF Viewer control rendering starts +pdfComparer.appendTo('#PdfComparer'); \ No newline at end of file diff --git a/Semantic Text Comparison/Programmatically get differences/index.html b/Semantic Text Comparison/Programmatically get differences/index.html new file mode 100644 index 0000000..1193f13 --- /dev/null +++ b/Semantic Text Comparison/Programmatically get differences/index.html @@ -0,0 +1,168 @@ + + + + + Syncfusion PDF Viewer - Semantic Text Comparison + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+
Original Document
+
+
+ + +
+
Modified Document
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/Semantic Text Comparison/Programmatically get differences/index.js b/Semantic Text Comparison/Programmatically get differences/index.js new file mode 100644 index 0000000..489b85f --- /dev/null +++ b/Semantic Text Comparison/Programmatically get differences/index.js @@ -0,0 +1,539 @@ +// ============================================================ +// Semantic Text Comparison Application +// ============================================================ + +const appState = { + viewer1: null, + viewer2: null, + loadedCount: 0, + viewersLoaded: false, + synchronizationEnabled: true, + highlightsEnabled: true, + comparisonResult: null +}; + +// ============================================================ +// UI Control Panel Creation +// ============================================================ + +function createControlPanel() { + const panel = document.getElementById('controlPanel'); + + panel.innerHTML = ` + + + + + + + +
+ + + + + + + + `; +} + +// ============================================================ +// Event Handlers +// ============================================================ + +function handleDocumentLoad() { + appState.loadedCount++; + console.log(`[App] Document loaded (${appState.loadedCount}/2)`); + + if (appState.loadedCount === 2 && appState.viewer1 && appState.viewer2) { + appState.viewersLoaded = true; + console.log('[App] Both viewers loaded - synchronization enabled'); + syncViewers(appState.viewer1, appState.viewer2, appState.synchronizationEnabled); + } +} + +function handleToggleSync() { + const newSyncState = !appState.synchronizationEnabled; + appState.synchronizationEnabled = newSyncState; + + const btnText = document.getElementById('syncBtnText'); + btnText.textContent = newSyncState ? 'Disable Sync' : 'Enable Sync'; + + if (appState.viewer1 && appState.viewer2) { + syncViewers(appState.viewer1, appState.viewer2, newSyncState); + console.log(`[App] Synchronization ${newSyncState ? 'enabled' : 'disabled'}`); + } +} + +function handleToggleHighlights() { + const newHighlightsState = !appState.highlightsEnabled; + appState.highlightsEnabled = newHighlightsState; + + const btnText = document.getElementById('highlightBtnText'); + btnText.textContent = newHighlightsState ? 'Disable Highlights' : 'Enable Highlights'; + + // Re-apply comparison with updated highlight state + if (appState.viewersLoaded && appState.viewer1 && appState.viewer2) { + handleCompare(); + } +} + +function handleClearAnnotations() { + try { + if (appState.viewer1 && appState.viewer2) { + console.log('[App] Clearing annotations'); + + // Remove semantic text compare annotations + if (typeof appState.viewer1.removeSemanticTextCompare === 'function') { + appState.viewer1.removeSemanticTextCompare(appState.viewer2); + } + + appState.comparisonResult = null; + console.log('[App] Annotations cleared'); + } + } catch (error) { + console.error('[App] Error clearing annotations:', error); + } +} + +// ============================================================ +// Synchronization +// ============================================================ + +function syncViewers(viewer1, viewer2, enabled) { + try { + if (enabled && typeof viewer1.syncViewers === 'function') { + viewer1.syncViewers(viewer2, true); + console.log('[App] Viewers synchronized'); + } else if (!enabled && typeof viewer1.syncViewers === 'function') { + viewer1.syncViewers(viewer2, false); + console.log('[App] Viewers desynchronized'); + } + } catch (error) { + console.error('[App] Error syncing viewers:', error); + } +} + +// ============================================================ +// Semantic Text Comparison Methods +// ============================================================ + +/** + * Performs semantic text comparison between two PDF documents + */ +async function handleCompare() { + try { + if (!appState.viewersLoaded || !appState.viewer1 || !appState.viewer2) { + console.warn('[App] Viewers not ready for comparison'); + return; + } + + console.log('[App] Starting semantic text comparison...'); + + const options = { + beforeColor: '#FF0000', // Red for original + afterColor: '#00FF00', // Green for modified + beforeColorOpacity: 0.4, + afterColorOpacity: 0.4, + enableHighlights: appState.highlightsEnabled + }; + + if (typeof appState.viewer1.semanticTextCompare !== 'function') { + console.error('[App] semanticTextCompare method not available'); + return; + } + + const result = await appState.viewer1.semanticTextCompare(appState.viewer2, options); + appState.comparisonResult = result; + + console.log('[App] Full Comparison Result:', result); + + // Parse the result structure + const originalAnnotations = result?.originalDocumentAnnotations || []; + const modifiedAnnotations = result?.modifiedDocumentAnnotations || []; + const totalTextDiffCount = result?.totalTextDiffCount || 0; + + console.log('[App] Total Text Differences:', totalTextDiffCount); + console.log('[App] Original Document Pages:', originalAnnotations.length); + console.log('[App] Modified Document Pages:', modifiedAnnotations.length); + + // Extract and categorize all differences + let addedCount = 0; + let deletedCount = 0; + let modifiedCount = 0; + + // Process original document annotations (deletions and modifications) + originalAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + console.log(`\n[Original] Page ${pageNum}:`); + pageAnnotations.differenceAnnotations?.forEach((diff) => { + const type = diff.textDiffType; + const text = diff.textDiffData; + if (type === 'deleted') deletedCount++; + if (type === 'modified') modifiedCount++; + if (type === 'added') addedCount++; + console.log(` - ${type.toUpperCase()}: "${text?.substring(0, 50)}..."`); + }); + }); + + // Process modified document annotations + modifiedAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + console.log(`\n[Modified] Page ${pageNum}:`); + pageAnnotations.differenceAnnotations?.forEach((diff) => { + const type = diff.textDiffType; + const text = diff.textDiffData; + console.log(` - ${type.toUpperCase()}: "${text?.substring(0, 50)}..."`); + }); + }); + + // Display summary + console.log('\n=== COMPARISON SUMMARY ==='); + console.log(`Total Differences: ${totalTextDiffCount}`); + console.log(`Deleted: ${deletedCount}`); + console.log(`Added: ${addedCount}`); + console.log(`Modified: ${modifiedCount}`); + + } catch (error) { + console.error('[App] Error during comparison:', error); + } +} + +/** + * Filters and retrieves differences by type (Added, Deleted, Modified) + */ +async function getDifferencesByType(type) { + try { + if (!appState.viewer1 || !appState.viewer2) { + console.warn('[App] Viewers not ready'); + return []; + } + + console.log(`[App] Getting ${type} differences...`); + + const options = { + beforeColor: '#FF0000', + afterColor: '#00FF00', + beforeColorOpacity: 0.4, + afterColorOpacity: 0.4, + enableHighlights: true + }; + + if (typeof appState.viewer1.semanticTextCompare !== 'function') { + console.error('[App] semanticTextCompare method not available'); + return []; + } + + const result = await appState.viewer1.semanticTextCompare(appState.viewer2, options); + appState.comparisonResult = result; + + const originalAnnotations = result?.originalDocumentAnnotations || []; + const modifiedAnnotations = result?.modifiedDocumentAnnotations || []; + const differences = []; + + // Extract differences by type from original annotations + originalAnnotations.forEach((pageAnnotations) => { + pageAnnotations.differenceAnnotations?.forEach((diff) => { + if (diff.textDiffType === type.toLowerCase()) { + differences.push({ + pageNumber: pageAnnotations.pageNumber, + type: diff.textDiffType, + text: diff.textDiffData, + bounds: diff.annotation?.bounds, + color: diff.annotation?.color + }); + } + }); + }); + + // Extract differences by type from modified annotations + modifiedAnnotations.forEach((pageAnnotations) => { + pageAnnotations.differenceAnnotations?.forEach((diff) => { + if (diff.textDiffType === type.toLowerCase()) { + // Avoid duplicates + const exists = differences.find(d => + d.pageNumber === pageAnnotations.pageNumber && + d.text === diff.textDiffData + ); + if (!exists) { + differences.push({ + pageNumber: pageAnnotations.pageNumber, + type: diff.textDiffType, + text: diff.textDiffData, + bounds: diff.annotation?.bounds, + color: diff.annotation?.color + }); + } + } + }); + }); + + console.log(`[App] ${type} differences (${differences.length}):`, differences); + return differences; + + } catch (error) { + console.error('[App] Error filtering differences:', error); + return []; + } +} + +/** + * Groups all differences by page number + */ +async function groupDifferencesByPage() { + try { + if (!appState.viewer1 || !appState.viewer2) { + console.warn('[App] Viewers not ready'); + return {}; + } + + console.log('[App] Grouping differences by page...'); + + const options = { + beforeColor: '#FF0000', + afterColor: '#00FF00', + beforeColorOpacity: 0.4, + afterColorOpacity: 0.4, + enableHighlights: true + }; + + if (typeof appState.viewer1.semanticTextCompare !== 'function') { + console.error('[App] semanticTextCompare method not available'); + return {}; + } + + const result = await appState.viewer1.semanticTextCompare(appState.viewer2, options); + appState.comparisonResult = result; + + const originalAnnotations = result?.originalDocumentAnnotations || []; + const modifiedAnnotations = result?.modifiedDocumentAnnotations || []; + const grouped = {}; + + // Group original document differences by page + originalAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + if (!grouped[pageNum]) { + grouped[pageNum] = { original: [], modified: [] }; + } + pageAnnotations.differenceAnnotations?.forEach((diff) => { + grouped[pageNum].original.push({ + type: diff.textDiffType, + text: diff.textDiffData, + bounds: diff.annotation?.bounds, + color: diff.annotation?.color + }); + }); + }); + + // Group modified document differences by page + modifiedAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + if (!grouped[pageNum]) { + grouped[pageNum] = { original: [], modified: [] }; + } + pageAnnotations.differenceAnnotations?.forEach((diff) => { + grouped[pageNum].modified.push({ + type: diff.textDiffType, + text: diff.textDiffData, + bounds: diff.annotation?.bounds, + color: diff.annotation?.color + }); + }); + }); + + console.log('[App] Differences grouped by page:', grouped); + return grouped; + + } catch (error) { + console.error('[App] Error grouping differences:', error); + return {}; + } +} + +/** + * Generates a detailed comparison report + */ +async function generateReport() { + try { + if (!appState.viewer1 || !appState.viewer2) { + console.warn('[App] Viewers not ready'); + return; + } + + console.log('[App] Generating detailed comparison report...'); + + const options = { + beforeColor: '#FF0000', + afterColor: '#00FF00', + beforeColorOpacity: 0.4, + afterColorOpacity: 0.4, + enableHighlights: true + }; + + if (typeof appState.viewer1.semanticTextCompare !== 'function') { + console.error('[App] semanticTextCompare method not available'); + return; + } + + const result = await appState.viewer1.semanticTextCompare(appState.viewer2, options); + appState.comparisonResult = result; + + const originalAnnotations = result?.originalDocumentAnnotations || []; + const modifiedAnnotations = result?.modifiedDocumentAnnotations || []; + const totalTextDiffCount = result?.totalTextDiffCount || 0; + + let addedCount = 0; + let deletedCount = 0; + let modifiedCount = 0; + const byPage = {}; + + // Process original annotations + originalAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + if (!byPage[pageNum]) { + byPage[pageNum] = { deleted: 0, added: 0, modified: 0, details: [] }; + } + pageAnnotations.differenceAnnotations?.forEach((diff) => { + const type = diff.textDiffType; + if (type === 'deleted') { + deletedCount++; + byPage[pageNum].deleted++; + } else if (type === 'added') { + addedCount++; + byPage[pageNum].added++; + } else if (type === 'modified') { + modifiedCount++; + byPage[pageNum].modified++; + } + byPage[pageNum].details.push({ + type, + text: diff.textDiffData?.substring(0, 100), + color: diff.annotation?.color + }); + }); + }); + + // Process modified annotations + modifiedAnnotations.forEach((pageAnnotations) => { + const pageNum = pageAnnotations.pageNumber; + if (!byPage[pageNum]) { + byPage[pageNum] = { deleted: 0, added: 0, modified: 0, details: [] }; + } + pageAnnotations.differenceAnnotations?.forEach((diff) => { + const type = diff.textDiffType; + if (type === 'deleted') { + deletedCount++; + byPage[pageNum].deleted++; + } else if (type === 'added') { + addedCount++; + byPage[pageNum].added++; + } else if (type === 'modified') { + modifiedCount++; + byPage[pageNum].modified++; + } + byPage[pageNum].details.push({ + type, + text: diff.textDiffData?.substring(0, 100), + color: diff.annotation?.color + }); + }); + }); + + const report = { + totalDifferences: totalTextDiffCount, + summary: { + added: addedCount, + deleted: deletedCount, + modified: modifiedCount + }, + byPage: byPage, + timestamp: new Date().toISOString() + }; + + console.log('=== DETAILED COMPARISON REPORT ==='); + console.log(`Total Text Differences: ${report.totalDifferences}`); + console.log(`Added: ${report.summary.added}`); + console.log(`Deleted: ${report.summary.deleted}`); + console.log(`Modified: ${report.summary.modified}`); + console.log('\nBreakdown by Page:'); + Object.entries(byPage).forEach(([pageNum, data]) => { + console.log(` Page ${pageNum}: +${data.added} -${data.deleted} ~${data.modified}`); + }); + console.log('\nFull Report:', report); + + return report; + + } catch (error) { + console.error('[App] Error generating report:', error); + } +} + +// ============================================================ +// Initialization +// ============================================================ + +document.addEventListener('DOMContentLoaded', function () { + console.log('[App] Initializing Semantic Text Comparison...'); + + // Create control panel + createControlPanel(); + + // Initialize PDF Viewer 1 (Original Document) + appState.viewer1 = new ej.pdfviewer.PdfViewer({ + documentPath: 'https://cdn.syncfusion.com/content/pdf/original-document.pdf', + resourceUrl: 'https://cdn.syncfusion.com/ej2/34.2.4/dist/ej2-pdfviewer-lib', + documentLoad: handleDocumentLoad + }); + + ej.pdfviewer.PdfViewer.Inject( + ej.pdfviewer.Toolbar, + ej.pdfviewer.Magnification, + ej.pdfviewer.Navigation, + ej.pdfviewer.Annotation, + ej.pdfviewer.LinkAnnotation, + ej.pdfviewer.BookmarkView, + ej.pdfviewer.ThumbnailView, + ej.pdfviewer.Print, + ej.pdfviewer.TextSelection, + ej.pdfviewer.TextSearch, + ej.pdfviewer.FormFields, + ej.pdfviewer.FormDesigner, + ej.pdfviewer.PageOrganizer + ); + + appState.viewer1.appendTo('#pdfViewer1'); + console.log('[App] Viewer 1 initialized'); + + // Initialize PDF Viewer 2 (Modified Document) + appState.viewer2 = new ej.pdfviewer.PdfViewer({ + documentPath: 'https://cdn.syncfusion.com/content/pdf/modified-document.pdf', + resourceUrl: 'https://cdn.syncfusion.com/ej2/34.2.4/dist/ej2-pdfviewer-lib', + documentLoad: handleDocumentLoad + }); + + ej.pdfviewer.PdfViewer.Inject( + ej.pdfviewer.Toolbar, + ej.pdfviewer.Magnification, + ej.pdfviewer.Navigation, + ej.pdfviewer.Annotation, + ej.pdfviewer.LinkAnnotation, + ej.pdfviewer.BookmarkView, + ej.pdfviewer.ThumbnailView, + ej.pdfviewer.Print, + ej.pdfviewer.TextSelection, + ej.pdfviewer.TextSearch, + ej.pdfviewer.FormFields, + ej.pdfviewer.FormDesigner, + ej.pdfviewer.PageOrganizer + ); + + appState.viewer2.appendTo('#pdfViewer2'); + console.log('[App] Viewer 2 initialized'); + + console.log('[App] Initialization complete - Waiting for both viewers to load...'); +}); \ No newline at end of file