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