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
}