using DevExpress.Pdf; using DevExpress.Drawing; using System.Drawing; 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, Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft) { // 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. Convert coordinates if origin is TopLeft var pdfRectangle = rectangle; if (origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft) { var page = processor.Document.Pages[pageNumber - 1]; double pageHeight = page.CropBox.Height; // Convert Y coordinates: TopLeft → BottomLeft // TopLeft Y=0 → BottomLeft Y=pageHeight // TopLeft Y=pageHeight → BottomLeft Y=0 pdfRectangle = ( rectangle.X1, pageHeight - rectangle.Y2, // Y2 becomes Y1 (top → bottom) rectangle.X2, pageHeight - rectangle.Y1 // Y1 becomes Y2 (bottom → top) ); } // 5. Get page facade (zero-based index) var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1]; // 6. Create annotation rectangle from converted coordinates var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2); // 7. 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 // 8. Add annotation based on type switch (annotationType) { case Domain.Models.ValueObjects.AnnotationType.TextMarkup: AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.FreeText: AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.StickyNote: AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Circle: AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Square: AddSquareAnnotation(pageFacade, pdfRect, 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 #region AddStampAsync public async Task AddStampAsync( Stream pdfStream, Domain.Models.ValueObjects.StampType stampType, int[]? pageNumbers, (double X, double Y) position, (double Width, double Height)? size = null, Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft, string? text = null, string? fontName = null, double? fontSize = null, string? color = null, double? opacity = null, double? rotation = null, Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground, byte[]? imageBytes = null, Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null) { // 1. Validate stream if (pdfStream == null || pdfStream.Length == 0) throw new BadRequestException("PDF stream cannot be null or empty"); if (pdfStream.Position != 0) throw new BadRequestException("PDF stream position must be 0"); // 2. Validate stamp type requirements ValidateStampParameters(stampType, text, imageBytes, predefinedType); // 3. Validate optional parameters if (opacity.HasValue && (opacity.Value < 0.0 || opacity.Value > 1.0)) throw new BadRequestException("Opacity must be between 0.0 and 1.0"); if (rotation.HasValue && (rotation.Value < 0 || rotation.Value > 360)) throw new BadRequestException("Rotation must be between 0 and 360 degrees"); if (fontSize.HasValue && fontSize.Value <= 0) throw new BadRequestException("Font size must be positive"); // 4. Load PDF and calculate target pages using var processor = new PdfDocumentProcessor(); try { processor.LoadDocument(pdfStream); } catch (Exception ex) { throw new BadRequestException($"Failed to load PDF document: {ex.Message}"); } int totalPages = processor.Document.Pages.Count; int[] targetPages = CalculateTargetPages(totalPages, pageNumbers); // 5. Apply stamp to each target page foreach (int pageIndex in targetPages) { double pageHeight = processor.Document.Pages[pageIndex].CropBox.Height; // Convert position if origin is TopLeft var stampPosition = origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft ? (position.X, pageHeight - position.Y) : position; // Apply stamp based on type switch (stampType) { case Domain.Models.ValueObjects.StampType.Text: AddTextStamp(processor, pageIndex, stampPosition, text!, fontName, fontSize, color, opacity, rotation, placement, size); break; case Domain.Models.ValueObjects.StampType.Image: AddImageStamp(processor, pageIndex, stampPosition, imageBytes!, opacity, rotation, placement, size); break; case Domain.Models.ValueObjects.StampType.Predefined: AddPredefinedStamp(processor, pageIndex, stampPosition, predefinedType!.Value, opacity, rotation, placement, size); break; } } // 6. Save to byte array using var outputStream = new MemoryStream(); processor.SaveDocument(outputStream); return await Task.FromResult(outputStream.ToArray()); } private void ValidateStampParameters( Domain.Models.ValueObjects.StampType stampType, string? text, byte[]? imageBytes, Domain.Models.ValueObjects.PredefinedStampType? predefinedType) { switch (stampType) { case Domain.Models.ValueObjects.StampType.Text: if (string.IsNullOrWhiteSpace(text)) throw new BadRequestException("Text is required for Text stamp type"); break; case Domain.Models.ValueObjects.StampType.Image: if (imageBytes == null || imageBytes.Length == 0) throw new BadRequestException("Image bytes are required for Image stamp type"); break; case Domain.Models.ValueObjects.StampType.Predefined: if (!predefinedType.HasValue) throw new BadRequestException("Predefined type is required for Predefined stamp type"); break; } } private int[] CalculateTargetPages(int totalPages, int[]? pageNumbers) { // null = all pages if (pageNumbers == null) return Enumerable.Range(0, totalPages).ToArray(); // Validate page numbers (1-based) foreach (int pageNum in pageNumbers) { if (pageNum < 1 || pageNum > totalPages) throw new BadRequestException($"Page number {pageNum} is out of range (1-{totalPages})"); } // Convert to zero-based indices return pageNumbers.Select(p => p - 1).Distinct().OrderBy(p => p).ToArray(); } private void AddTextStamp( PdfDocumentProcessor processor, int pageIndex, (double X, double Y) position, string text, string? fontName, double? fontSize, string? color, double? opacity, double? rotation, Domain.Models.ValueObjects.StampPlacement placement, (double Width, double Height)? size) { using var graphics = processor.CreateGraphicsPageSystem(); // Get page object PdfPage page = processor.Document.Pages[pageIndex]; // Parse color (default: black) var pdfColor = ParseColor(color) ?? new PdfRGBColor(0, 0, 0); // Apply opacity (default: 0.5) by creating color with alpha channel double alpha = opacity ?? 0.5; Color drawColor = Color.FromArgb((int)(alpha * 255), (int)(pdfColor.R * 255), (int)(pdfColor.G * 255), (int)(pdfColor.B * 255)); // Create font (default: Arial, 12pt) var font = new DXFont(fontName ?? "Arial", (float)(fontSize ?? 12)); // Calculate bounds var bounds = size.HasValue ? new RectangleF((float)position.X, (float)position.Y, (float)size.Value.Width, (float)size.Value.Height) : new RectangleF((float)position.X, (float)position.Y, 200, 50); // Default size // Apply rotation if specified (around origin, not center point) if (rotation.HasValue && rotation.Value > 0) { // Translate to position, rotate, translate back graphics.TranslateTransform((float)position.X, (float)position.Y); graphics.RotateTransform((float)rotation.Value); graphics.TranslateTransform(-(float)position.X, -(float)position.Y); } // Draw text graphics.DrawString(text, font, new DXSolidBrush(drawColor), bounds); // Add graphics to page (foreground or background) if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground) graphics.AddToPageForeground(page); else graphics.AddToPageBackground(page); } private void AddImageStamp( PdfDocumentProcessor processor, int pageIndex, (double X, double Y) position, byte[] imageBytes, double? opacity, double? rotation, Domain.Models.ValueObjects.StampPlacement placement, (double Width, double Height)? size) { using var graphics = processor.CreateGraphicsPageSystem(); // Get page object PdfPage page = processor.Document.Pages[pageIndex]; // Draw image directly from byte array try { PointF point = new PointF((float)position.X, (float)position.Y); // Apply rotation if specified if (rotation.HasValue && rotation.Value > 0) { graphics.TranslateTransform((float)position.X, (float)position.Y); graphics.RotateTransform((float)rotation.Value); graphics.TranslateTransform(-(float)position.X, -(float)position.Y); } // Draw image (DevExpress.Pdf.PdfGraphics.DrawImage accepts byte[] directly) // Note: Size parameter is ignored for now (DrawImage auto-sizes based on image dimensions) // If size is needed, we'd need to use DXImage.FromStream and resize graphics.DrawImage(imageBytes, point); // Add graphics to page if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground) graphics.AddToPageForeground(page); else graphics.AddToPageBackground(page); } catch (Exception ex) { throw new BadRequestException($"Invalid image format or failed to draw image: {ex.Message}"); } } private void AddPredefinedStamp( PdfDocumentProcessor processor, int pageIndex, (double X, double Y) position, Domain.Models.ValueObjects.PredefinedStampType predefinedType, double? opacity, double? rotation, Domain.Models.ValueObjects.StampPlacement placement, (double Width, double Height)? size) { // Get predefined stamp configuration var (text, color, fontSize, fontStyle) = GetPredefinedStampConfig(predefinedType); // Delegate to AddTextStamp with predefined parameters AddTextStamp(processor, pageIndex, position, text, "Arial", fontSize, color, opacity, rotation, placement, size); } private (string Text, string Color, double FontSize, string FontStyle) GetPredefinedStampConfig( Domain.Models.ValueObjects.PredefinedStampType predefinedType) { return predefinedType switch { Domain.Models.ValueObjects.PredefinedStampType.Confidential => ("CONFIDENTIAL", "FF0000", 24, "Bold"), Domain.Models.ValueObjects.PredefinedStampType.Approved => ("APPROVED", "00AA00", 24, "Bold"), Domain.Models.ValueObjects.PredefinedStampType.Draft => ("DRAFT", "808080", 24, "Italic"), Domain.Models.ValueObjects.PredefinedStampType.Void => ("VOID", "FF0000", 32, "Bold"), Domain.Models.ValueObjects.PredefinedStampType.ForReview => ("FOR REVIEW", "FFA500", 20, "Bold"), _ => throw new BadRequestException($"Unsupported predefined stamp type: {predefinedType}") }; } #endregion #region Add Attachments (Phase 2) /// /// Embeds one or more files as attachments in a PDF document (supports PDF/A-3). /// public async Task AddAttachmentsAsync( Stream pdfStream, IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments) { // 1. Validate input ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); ArgumentNullException.ThrowIfNull(attachments, nameof(attachments)); 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"); if (attachments.Count == 0) throw new BadRequestException("At least one attachment is required"); // TODO: Implement AddFileAttachment using DevExpress.Pdf low-level API // Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support adding attachments // Workaround options: // 1. Use PdfDocumentProcessor.Document to manipulate PDF structure directly (advanced) // 2. Use third-party library for this specific operation // 3. Wait for DevExpress API update throw new NotImplementedException( "Add attachments feature is not yet implemented. " + "DevExpress.Pdf high-level API doesn't directly support adding file attachments. " + "This requires low-level PDF structure manipulation."); } private string InferMimeType(string fileName) { string extension = Path.GetExtension(fileName).ToLowerInvariant(); return extension switch { ".xml" => "application/xml", ".pdf" => "application/pdf", ".json" => "application/json", ".txt" => "text/plain", ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", _ => "application/octet-stream" }; } #endregion #region PDF Conversion (Phase 3) /// /// Converts a standard PDF to PDF/A format. /// public async Task ConvertToPdfAAsync(Stream pdfStream, string pdfALevel) { // 1. Validate input 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"); if (string.IsNullOrWhiteSpace(pdfALevel)) throw new BadRequestException("PDF/A level is required"); // TODO: Implement PDF to PDF/A conversion using DevExpress // Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support PDF/A conversion // Requires using specialized PDF/A conversion libraries or low-level PDF manipulation throw new NotImplementedException( $"PDF to PDF/A conversion ({pdfALevel}) is not yet implemented. " + "DevExpress.Pdf high-level API doesn't directly support PDF/A conversion. " + "This requires specialized PDF/A conversion logic."); } /// /// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions). /// public async Task ConvertFromPdfAAsync(Stream pdfStream) { // 1. Validate input 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"); // 2. Load PDF/A using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream); // 3. Save as standard PDF // DevExpress SaveDocument without special options creates standard PDF using var outputStream = new MemoryStream(); processor.SaveDocument(outputStream); return await Task.FromResult(outputStream.ToArray()); } #endregion }