diff --git a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs index ed4118a..7bfb97e 100644 --- a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs +++ b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs @@ -298,6 +298,98 @@ public class DevExpressPdfProcessor : IPdfProcessor #endregion + #region PDF Merge Operations + + public async Task MergePdfsAsync(IReadOnlyList pdfStreams, IReadOnlyList? pageRanges = null) + { + // 1. Validate input: minimum 2 PDFs required + if (pdfStreams == null || pdfStreams.Count < 2) + throw new BadRequestException("At least 2 PDF files are required for merging"); + + // 2. Validate page ranges length (if provided) + if (pageRanges != null && pageRanges.Count != pdfStreams.Count) + throw new BadRequestException($"Page ranges count ({pageRanges.Count}) must match PDF files count ({pdfStreams.Count})"); + + // 3. Defensive validation: all streams must be at Position = 0 + for (int i = 0; i < pdfStreams.Count; i++) + { + var stream = pdfStreams[i]; + + if (stream == null) + throw new BadRequestException($"PDF stream at index {i} is null"); + + if (stream.Length == 0) + throw new BadRequestException($"PDF stream at index {i} is empty"); + + if (stream.CanSeek && stream.Position != 0) + throw new BadRequestException(null, new ArgumentException( + $"PDF stream at index {i} must be positioned at the beginning (Position = 0).", + nameof(pdfStreams))); + } + + // 4. Create merged PDF using DevExpress + using var mergedProcessor = new PdfDocumentProcessor(); + + // Load first PDF as base document + mergedProcessor.LoadDocument(pdfStreams[0]); + + // Apply page range to first PDF if specified + if (pageRanges != null && !string.IsNullOrWhiteSpace(pageRanges[0])) + { + var pageIndices = ParsePageRange(pageRanges[0]!, mergedProcessor.Document.Pages.Count); + // Remove pages not in range (process in reverse to maintain indices) + for (int i = mergedProcessor.Document.Pages.Count - 1; i >= 0; i--) + { + if (!pageIndices.Contains(i)) + mergedProcessor.Document.Pages.RemoveAt(i); + } + } + + // Append remaining PDFs + for (int i = 1; i < pdfStreams.Count; i++) + { + string? pageRange = pageRanges?[i]; + + if (string.IsNullOrWhiteSpace(pageRange)) + { + // Append all pages + mergedProcessor.AppendDocument(pdfStreams[i]); + } + else + { + // Parse page range and append selected pages + // Note: We need to load the document first to validate page range + using var tempProcessor = new PdfDocumentProcessor(); + tempProcessor.LoadDocument(pdfStreams[i]); + + var pageIndices = ParsePageRange(pageRange, tempProcessor.Document.Pages.Count); + + // DevExpress AppendDocument doesn't support arbitrary page selection + // Workaround: Create temp PDF with selected pages, then append + using var tempStream = new MemoryStream(); + + // Remove unwanted pages from temp document (in reverse order) + for (int j = tempProcessor.Document.Pages.Count - 1; j >= 0; j--) + { + if (!pageIndices.Contains(j)) + tempProcessor.Document.Pages.RemoveAt(j); + } + + tempProcessor.SaveDocument(tempStream); + tempStream.Position = 0; + mergedProcessor.AppendDocument(tempStream); + } + } + + // 5. Save merged PDF to byte array + using var outputStream = new MemoryStream(); + mergedProcessor.SaveDocument(outputStream); + + return await Task.FromResult(outputStream.ToArray()); + } + + #endregion + #region Private Helpers /// @@ -408,5 +500,77 @@ public class DevExpressPdfProcessor : IPdfProcessor } } + /// + /// Parses page range string into list of zero-based page indices. + /// + /// Page range string (e.g., "1-3,5" or "1,3,5") + /// Total page count in PDF (for validation) + /// List of zero-based page indices + /// Invalid format or page number out of range + private static List ParsePageRange(string pageRange, int totalPages) + { + var pageIndices = new HashSet(); // Use HashSet to avoid duplicates + + try + { + // Split by comma + string[] parts = pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (string part in parts) + { + if (part.Contains('-')) + { + // Range format: "1-3" + string[] rangeParts = part.Split('-', StringSplitOptions.TrimEntries); + if (rangeParts.Length != 2) + throw new BadRequestException($"Invalid page range format: '{part}'. Expected format: '1-3'"); + + if (!int.TryParse(rangeParts[0], out int start) || !int.TryParse(rangeParts[1], out int end)) + throw new BadRequestException($"Invalid page numbers in range: '{part}'"); + + if (start < 1 || end < 1) + throw new BadRequestException($"Page numbers must be >= 1 in range: '{part}'"); + + if (start > end) + throw new BadRequestException($"Start page must be <= end page in range: '{part}'"); + + if (start > totalPages || end > totalPages) + throw new BadRequestException($"Page range '{part}' exceeds document page count ({totalPages})"); + + // Add pages (convert to zero-based indices) + for (int i = start; i <= end; i++) + pageIndices.Add(i - 1); + } + else + { + // Single page: "5" + if (!int.TryParse(part, out int pageNum)) + throw new BadRequestException($"Invalid page number: '{part}'"); + + if (pageNum < 1) + throw new BadRequestException($"Page number must be >= 1: '{part}'"); + + if (pageNum > totalPages) + throw new BadRequestException($"Page number {pageNum} exceeds document page count ({totalPages})"); + + pageIndices.Add(pageNum - 1); // Convert to zero-based index + } + } + } + catch (BadRequestException) + { + throw; // Re-throw BadRequestException as-is + } + catch (Exception ex) + { + throw new BadRequestException($"Invalid page range format: '{pageRange}'. Error: {ex.Message}"); + } + + if (pageIndices.Count == 0) + throw new BadRequestException($"Page range '{pageRange}' resulted in no pages"); + + return pageIndices.OrderBy(x => x).ToList(); // Return sorted list + } + #endregion }