using DevExpress.Pdf; using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Domain.Common.Exceptions; namespace DocumentOperator.Infrastructure.Services.PdfProcessing; /// /// PDF processor implementation using DevExpress.Pdf library. /// Handles PDF validation, metadata extraction, and attachment operations. /// public class DevExpressPdfProcessor : IPdfProcessor { #region PDF Validation /// /// Validates a PDF document and returns metadata. /// /// PDF content as stream (caller is responsible for disposal) /// PDF metadata (page count, file size, version, etc.) /// Thrown when stream is empty or invalid public async Task ValidateAsync(Stream pdfStream) { // 1. Input Validation ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); if (pdfStream.Length == 0) { throw new BadRequestException("PDF stream cannot be empty"); } // Defensive validation: Seekable streams must be at Position = 0 if (pdfStream.CanSeek && pdfStream.Position != 0) { throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0)."); } // 2. Read stream to byte array for raw data analysis // (DevExpress needs byte[] for some operations like attachment detection) byte[] pdfBytes; if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer)) { // Fast path: reuse MemoryStream buffer pdfBytes = buffer.Array!; } else { // Slow path: copy stream to byte array using var memoryStream = new MemoryStream(); await pdfStream.CopyToAsync(memoryStream); pdfBytes = memoryStream.ToArray(); } // 3. Load PDF with DevExpress Document API // Reset position for DevExpress (seekable streams only) if (pdfStream.CanSeek) { pdfStream.Position = 0; } using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); // 4. Extract metadata var document = processor.Document; int pageCount = document.Pages.Count; string pdfVersion = document.Version.ToString(); // e.g., "1.4", "1.7" // Attachments (embedded files) // DevExpress PdfDocument API doesn't expose EmbeddedFiles directly in old API. // We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count. var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes); // 5. Create and return PdfMetadata DTO return new Application.Common.DTOs.PdfMetadata( pageCount: pageCount, fileSizeBytes: pdfBytes.Length, pdfVersion: pdfVersion, hasAttachments: hasAttachments, attachmentCount: attachmentCount ); } /// /// Validates a PDF/A document and checks conformance level. /// /// PDF content as stream (caller is responsible for disposal) /// PDF/A metadata including conformance level and validation errors/warnings /// Thrown when stream is empty or invalid public async Task ValidatePdfAAsync(Stream pdfStream) { // 1. Input Validation ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); if (pdfStream.Length == 0) { throw new BadRequestException("PDF stream cannot be empty"); } // Defensive validation: Seekable streams must be at Position = 0 if (pdfStream.CanSeek && pdfStream.Position != 0) { throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0)."); } // 2. Read stream to byte array for raw data analysis byte[] pdfBytes; if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer)) { pdfBytes = buffer.Array!; } else { using var memoryStream = new MemoryStream(); await pdfStream.CopyToAsync(memoryStream); pdfBytes = memoryStream.ToArray(); } // 3. Load PDF with DevExpress Document API // Reset position for DevExpress (seekable streams only) if (pdfStream.CanSeek) { pdfStream.Position = 0; } using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); var document = processor.Document; // 4. Extract basic metadata int pageCount = document.Pages.Count; string pdfVersion = document.Version.ToString(); // 5. Check encryption (scan PDF raw data for /Encrypt keyword) bool encrypted = DetectEncryption(pdfBytes); // 6. Check PDF/A conformance (scan PDF raw data for PDF/A identifier) var (isPdfACompliant, pdfaVersion) = DetectPdfAConformance(pdfBytes); // 7. Collect errors and warnings var errors = new List(); var warnings = new List(); // If encrypted, PDF/A compliance is not possible if (encrypted && isPdfACompliant) { errors.Add("PDF/A documents cannot be encrypted"); isPdfACompliant = false; } // Basic PDF/A validation checks if (isPdfACompliant) { // Add generic warning for manual verification warnings.Add("Manual verification recommended: All fonts must be embedded"); warnings.Add("Manual verification recommended: No JavaScript or multimedia content"); } // 8. Determine overall validity bool isValid = errors.Count == 0; // 9. Create and return PdfAMetadata DTO return new PdfAMetadata( isValid: isValid, pdfVersion: pdfVersion, pageCount: pageCount, fileSizeBytes: pdfBytes.Length, encrypted: encrypted, pdfaVersion: pdfaVersion, pdfaCompliant: isPdfACompliant, errors: errors, warnings: warnings ); } #endregion #region Attachment Operations /// /// Checks for embedded files (attachments) in a PDF document and returns detailed metadata. /// Uses DevExpress PdfDocument.FileAttachments collection to retrieve attachment details. /// /// PDF content as stream (caller is responsible for disposal) /// Attachment information including count, file names, MIME types, and sizes /// Thrown when stream is empty public async Task CheckAttachmentsAsync(Stream pdfStream) { // 1. Input Validation ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); if (pdfStream.Length == 0) { throw new BadRequestException("PDF stream cannot be empty"); } // Defensive validation: Seekable streams must be at Position = 0 if (pdfStream.CanSeek && pdfStream.Position != 0) { throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0)."); } // 2. Load PDF with DevExpress Document API // DevExpress LoadDocument may throw exceptions for corrupted PDFs - let them propagate naturally // Middleware will catch and convert to 500 Internal Server Error using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); var document = processor.Document; // 3. Extract attachment details using DevExpress FileAttachments collection var fileAttachments = document.FileAttachments; // 4. No attachments case (FileAttachments is IEnumerable) if (fileAttachments == null || !fileAttachments.Any()) { return AttachmentInfo.Empty; } // 5. Map DevExpress PdfFileAttachment to our DTO AttachmentMetadata var attachments = fileAttachments.Select(devExpressAttachment => new AttachmentMetadata( fileName: devExpressAttachment.FileName ?? "unnamed", mimeType: devExpressAttachment.MimeType ?? "application/octet-stream", sizeBytes: devExpressAttachment.Size )).ToList(); // 6. Return AttachmentInfo DTO return new AttachmentInfo( hasAttachments: true, attachmentCount: attachments.Count, attachments: attachments.AsReadOnly() ); } /// /// Extracts all embedded files from a PDF document and returns them as a ZIP archive. /// Uses DevExpress PdfDocument.FileAttachments to retrieve attachment data. /// /// PDF content as stream (caller is responsible for disposal) /// ZIP archive byte array containing all extracted attachments /// Thrown when stream is empty /// Thrown when PDF contains no attachments public async Task ExtractAttachmentsAsync(Stream pdfStream) { // 1. Input Validation ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); if (pdfStream.Length == 0) { throw new BadRequestException("PDF stream cannot be empty"); } // Defensive validation: Seekable streams must be at Position = 0 if (pdfStream.CanSeek && pdfStream.Position != 0) { throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0)."); } // 2. Load PDF with DevExpress Document API using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); var document = processor.Document; // 3. Extract attachment data using DevExpress FileAttachments collection var fileAttachments = document.FileAttachments; // 4. No attachments case if (fileAttachments == null || !fileAttachments.Any()) { throw new NotFoundException("PDF does not contain any attachments"); } // 5. Create ZIP archive in memory using var zipStream = new MemoryStream(); using (var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) { foreach (var attachment in fileAttachments) { // Get attachment metadata string fileName = attachment.FileName ?? "unnamed"; byte[] fileData = attachment.Data; // Create entry in ZIP var entry = zipArchive.CreateEntry(fileName, System.IO.Compression.CompressionLevel.Optimal); // Write attachment data to ZIP entry using var entryStream = entry.Open(); await entryStream.WriteAsync(fileData, 0, fileData.Length); } } // 6. Return ZIP byte array return zipStream.ToArray(); } #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 PDF Annotation /// /// Adds an annotation to a PDF document at the specified page and rectangle. /// public async Task AddAnnotationAsync( Stream pdfStream, Domain.Models.ValueObjects.AnnotationType annotationType, int pageNumber, (double X1, double Y1, double X2, double Y2) rectangle, string? content = null, string? author = null, string? color = null, Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null) { // 1. Input validation ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); if (pdfStream.Length == 0) throw new BadRequestException("PDF stream cannot be empty"); if (pdfStream.Position != 0) throw new BadRequestException($"PDF stream must be at position 0 (current position: {pdfStream.Position})"); if (pageNumber < 1) throw new BadRequestException($"Page number must be >= 1 (provided: {pageNumber})"); // Validate content requirement if (annotationType is Domain.Models.ValueObjects.AnnotationType.FreeText or Domain.Models.ValueObjects.AnnotationType.StickyNote) { if (string.IsNullOrWhiteSpace(content)) throw new BadRequestException($"{annotationType} annotation requires content"); } // Validate TextMarkup style requirement if (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup && textMarkupStyle == null) throw new BadRequestException("TextMarkup annotation requires textMarkupStyle parameter"); byte[] annotatedPdfBytes; try { // 2. Load PDF using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); // 3. Validate page number int pageCount = processor.Document.Pages.Count; if (pageNumber > pageCount) throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})"); // 4. Get page facade (zero-based index) var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1]; // 5. Create annotation rectangle var pdfRectangle = new PdfRectangle(rectangle.X1, rectangle.Y1, rectangle.X2, rectangle.Y2); // 6. Parse color (default to yellow for highlights, red for others) PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup ? new PdfRGBColor(1.0, 1.0, 0) // Yellow : new PdfRGBColor(1.0, 0, 0)); // Red // 7. Add annotation based on type switch (annotationType) { case Domain.Models.ValueObjects.AnnotationType.TextMarkup: AddTextMarkupAnnotation(pageFacade, pdfRectangle, textMarkupStyle!.Value, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.FreeText: AddFreeTextAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.StickyNote: AddStickyNoteAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Circle: AddCircleAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Square: AddSquareAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); break; default: throw new BadRequestException($"Unsupported annotation type: {annotationType}"); } // 8. Save annotated PDF using var outputStream = new MemoryStream(); processor.SaveDocument(outputStream); annotatedPdfBytes = outputStream.ToArray(); } catch (BadRequestException) { throw; // Re-throw our own exceptions } catch (Exception ex) { throw new BadRequestException($"Failed to add annotation: {ex.Message}"); } return await Task.FromResult(annotatedPdfBytes); } private void AddTextMarkupAnnotation( PdfPageFacade pageFacade, PdfRectangle rectangle, Domain.Models.ValueObjects.TextMarkupStyle style, string? content, string? author, PdfRGBColor color) { // Map our enum to DevExpress enum var devExpressStyle = style switch { Domain.Models.ValueObjects.TextMarkupStyle.Highlight => PdfTextMarkupAnnotationType.Highlight, Domain.Models.ValueObjects.TextMarkupStyle.Underline => PdfTextMarkupAnnotationType.Underline, Domain.Models.ValueObjects.TextMarkupStyle.Strikeout => PdfTextMarkupAnnotationType.StrikeOut, _ => throw new BadRequestException($"Unsupported text markup style: {style}") }; var annotation = pageFacade.AddTextMarkupAnnotation(rectangle, devExpressStyle); if (annotation != null) { annotation.Color = color; if (!string.IsNullOrWhiteSpace(author)) annotation.Author = author; if (!string.IsNullOrWhiteSpace(content)) annotation.Contents = content; } } private void AddFreeTextAnnotation( PdfPageFacade pageFacade, PdfRectangle rectangle, string content, string? author, PdfRGBColor color) { var annotation = pageFacade.AddFreeTextAnnotation(rectangle, content); if (annotation != null) { annotation.Color = color; if (!string.IsNullOrWhiteSpace(author)) annotation.Author = author; } } private void AddStickyNoteAnnotation( PdfPageFacade pageFacade, PdfRectangle rectangle, string content, string? author, PdfRGBColor color) { // Sticky note uses a point (top-left corner of rectangle) var point = new PdfPoint(rectangle.Left, rectangle.Top); var annotation = pageFacade.AddTextAnnotation(point); if (annotation != null) { annotation.Color = color; annotation.Contents = content; if (!string.IsNullOrWhiteSpace(author)) annotation.Author = author; } } private void AddCircleAnnotation( PdfPageFacade pageFacade, PdfRectangle rectangle, string? content, string? author, PdfRGBColor color) { var annotation = pageFacade.AddCircleAnnotation(rectangle); if (annotation != null) { annotation.Color = color; if (!string.IsNullOrWhiteSpace(author)) annotation.Author = author; if (!string.IsNullOrWhiteSpace(content)) annotation.Contents = content; } } private void AddSquareAnnotation( PdfPageFacade pageFacade, PdfRectangle rectangle, string? content, string? author, PdfRGBColor color) { var annotation = pageFacade.AddSquareAnnotation(rectangle); if (annotation != null) { annotation.Color = color; if (!string.IsNullOrWhiteSpace(author)) annotation.Author = author; if (!string.IsNullOrWhiteSpace(content)) annotation.Contents = content; } } /// /// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor /// private PdfRGBColor? ParseColor(string? hexColor) { if (string.IsNullOrWhiteSpace(hexColor)) return null; try { // Remove '#' if present hexColor = hexColor.TrimStart('#'); if (hexColor.Length != 6) throw new BadRequestException($"Color must be 6-digit hex (e.g., 'FF0000'), got: '{hexColor}'"); int r = Convert.ToInt32(hexColor.Substring(0, 2), 16); int g = Convert.ToInt32(hexColor.Substring(2, 2), 16); int b = Convert.ToInt32(hexColor.Substring(4, 2), 16); return new PdfRGBColor(r / 255.0, g / 255.0, b / 255.0); } catch (Exception ex) { throw new BadRequestException($"Invalid color format: '{hexColor}'. Expected 6-digit hex (e.g., 'FF0000'). Error: {ex.Message}"); } } #endregion #region Private Helpers /// /// Detects if PDF is encrypted by scanning for /Encrypt keyword. /// private static bool DetectEncryption(byte[] pdfBytes) { string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes); return pdfText.Contains("/Encrypt", StringComparison.Ordinal); } /// /// Detects PDF/A conformance level by scanning PDF metadata. /// PDF/A documents contain an XMP metadata stream with pdfaid:conformance and pdfaid:part. /// private static (bool isPdfACompliant, string? pdfaVersion) DetectPdfAConformance(byte[] pdfBytes) { string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes); // Look for PDF/A identifier in XMP metadata // Example: 1B if (pdfText.Contains("pdfaid:part", StringComparison.Ordinal)) { // Try to extract part and conformance level var partMatch = System.Text.RegularExpressions.Regex.Match(pdfText, @"pdfaid:part>(\d+)([ABU]) /// Detects embedded files in PDF by scanning raw PDF data for /EmbeddedFiles keyword /// and parsing the name tree to count attachments. /// This is a pragmatic approach as DevExpress PdfDocument API doesn't expose EmbeddedFiles directly. /// /// PDF raw bytes /// Tuple: (hasAttachments, attachmentCount) private static (bool hasAttachments, int attachmentCount) DetectEmbeddedFiles(byte[] pdfBytes) { // PDF embedded files are declared in the document catalog: // /Names << /EmbeddedFiles << /Names [...] >> >> // The /Names array contains pairs: [name1, filespec1, name2, filespec2, ...] string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes); // Search for /EmbeddedFiles in the context of /Names dictionary // Must appear after a /Names keyword to be valid int searchStart = 0; while (true) { // Find next occurrence of /EmbeddedFiles int embeddedFilesIndex = pdfText.IndexOf("/EmbeddedFiles", searchStart, StringComparison.Ordinal); if (embeddedFilesIndex == -1) return (false, 0); // Not found // Check if there's a /Names keyword BEFORE this /EmbeddedFiles // within a reasonable distance (e.g., within the same PDF object, max 5000 chars back) int contextStart = Math.Max(0, embeddedFilesIndex - 5000); string contextBefore = pdfText.Substring(contextStart, embeddedFilesIndex - contextStart); // Look for /Names in the context before /EmbeddedFiles int lastNamesIndex = contextBefore.LastIndexOf("/Names", StringComparison.Ordinal); if (lastNamesIndex != -1) { // Found /Names before /EmbeddedFiles - this is likely a valid embedded files declaration // Now try to parse the /Names array int namesArrayStart = pdfText.IndexOf("/Names", embeddedFilesIndex, StringComparison.Ordinal); if (namesArrayStart == -1) return (true, 0); // Has EmbeddedFiles but can't count int arrayStart = pdfText.IndexOf('[', namesArrayStart); if (arrayStart == -1) return (true, 0); // Has EmbeddedFiles but can't count int arrayEnd = pdfText.IndexOf(']', arrayStart); if (arrayEnd == -1) return (true, 0); // Has EmbeddedFiles but can't count // Extract array content and count entries string arrayContent = pdfText.Substring(arrayStart + 1, arrayEnd - arrayStart - 1); // Count object references in array // The /Names array contains pairs: (filename) objectReference (filename) objectReference ... // Each object reference (pattern: "123 0 R") points to one embedded file // So the number of object references = number of attachments int objectCount = System.Text.RegularExpressions.Regex.Matches(arrayContent, @"\d+ \d+ R").Count; return (true, Math.Max(1, objectCount)); // At least 1 if EmbeddedFiles found } // This /EmbeddedFiles was not in the right context, search for next occurrence searchStart = embeddedFilesIndex + 1; } } /// /// 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 }