diff --git a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs index e688818..12b231d 100644 --- a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs +++ b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs @@ -1,150 +1,216 @@ using DevExpress.Pdf; +using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Domain.Common.Exceptions; -using DocumentOperator.Domain.Models.ValueObjects; namespace DocumentOperator.Infrastructure.Services.PdfProcessing; /// /// PDF processor implementation using DevExpress.Pdf library. -/// Handles PDF validation and metadata extraction. +/// 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 byte array + /// PDF content as stream (caller is responsible for disposal) /// PDF metadata (page count, file size, version, etc.) - /// Thrown when PDF is invalid or null - public async Task ValidateAsync(byte[] pdfBytes) + /// Thrown when stream is empty or invalid + public async Task ValidateAsync(Stream pdfStream) { - // 1. Input Validation (Defensive Programming) - if (pdfBytes == null) + // 1. Input Validation + ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); + + if (pdfStream.Length == 0) { - throw new PdfProcessingException("PDF bytes cannot be null"); + throw new BadRequestException("PDF stream cannot be empty"); } - if (pdfBytes.Length == 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)) { - throw new PdfProcessingException("PDF bytes cannot be empty"); + // Fast path: reuse MemoryStream buffer + pdfBytes = buffer.Array!; + } + else + { + // Slow path: copy stream to byte array + pdfStream.Position = 0; + using var memoryStream = new MemoryStream(); + await pdfStream.CopyToAsync(memoryStream); + pdfBytes = memoryStream.ToArray(); } - try - { - // 2. Load PDF with DevExpress Document API (PdfDocumentProcessor) - using var processor = new PdfDocumentProcessor(); - processor.LoadDocument(new MemoryStream(pdfBytes)); + // 3. Load PDF with DevExpress Document API + pdfStream.Position = 0; + using var processor = new PdfDocumentProcessor(); + processor.LoadDocument(pdfStream); - // 3. Extract metadata - var document = processor.Document; + // 4. Extract metadata + var document = processor.Document; - int pageCount = document.Pages.Count; - string pdfVersion = document.Version.ToString(); // z.B. "1.4", "1.7" + 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. - // We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count. - var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes); + // 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); - // 4. Create and return PdfMetadata Value Object - return new Domain.Models.ValueObjects.PdfMetadata( - pageCount: pageCount, - fileSizeBytes: pdfBytes.Length, - pdfVersion: pdfVersion, - hasAttachments: hasAttachments, - attachmentCount: attachmentCount - ); - } - catch (Exception ex) when (ex is not PdfProcessingException) - { - // Wrap DevExpress exceptions in our domain exception - throw new PdfProcessingException( - $"Failed to validate PDF: {ex.Message}", - ex); - } + // 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 byte array + /// PDF content as stream (caller is responsible for disposal) /// PDF/A metadata including conformance level and validation errors/warnings - /// Thrown when PDF is invalid or null - public async Task ValidatePdfAAsync(byte[] pdfBytes) + /// Thrown when stream is empty or invalid + public async Task ValidatePdfAAsync(Stream pdfStream) { // 1. Input Validation - if (pdfBytes == null) + ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); + + if (pdfStream.Length == 0) { - throw new PdfProcessingException("PDF bytes cannot be null"); + throw new BadRequestException("PDF stream cannot be empty"); } - if (pdfBytes.Length == 0) + // 2. Read stream to byte array for raw data analysis + byte[] pdfBytes; + if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer)) { - throw new PdfProcessingException("PDF bytes cannot be empty"); + pdfBytes = buffer.Array!; + } + else + { + pdfStream.Position = 0; + using var memoryStream = new MemoryStream(); + await pdfStream.CopyToAsync(memoryStream); + pdfBytes = memoryStream.ToArray(); } - try + // 3. Load PDF with DevExpress Document API + 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) { - // 2. Load PDF with DevExpress Document API - using var processor = new PdfDocumentProcessor(); - processor.LoadDocument(new MemoryStream(pdfBytes)); - - var document = processor.Document; - - // 3. Extract basic metadata - int pageCount = document.Pages.Count; - string pdfVersion = document.Version.ToString(); - - // 4. Check encryption (scan PDF raw data for /Encrypt keyword) - bool encrypted = DetectEncryption(pdfBytes); - - // 5. Check PDF/A conformance (scan PDF raw data for PDF/A identifier) - var (isPdfACompliant, pdfaVersion) = DetectPdfAConformance(pdfBytes); - - // 6. 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"); - } - - // 7. Determine overall validity - bool isValid = errors.Count == 0; - - // 8. Create and return PdfAMetadata - return new PdfAMetadata( - isValid: isValid, - pdfVersion: pdfVersion, - pageCount: pageCount, - fileSizeBytes: pdfBytes.Length, - encrypted: encrypted, - pdfaVersion: pdfaVersion, - pdfaCompliant: isPdfACompliant, - errors: errors, - warnings: warnings - ); + errors.Add("PDF/A documents cannot be encrypted"); + isPdfACompliant = false; } - catch (Exception ex) when (ex is not PdfProcessingException) + + // Basic PDF/A validation checks + if (isPdfACompliant) { - throw new PdfProcessingException( - $"Failed to validate PDF/A: {ex.Message}", - ex); + // 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"); + } + + // 2. Load PDF with DevExpress Document API (exceptions propagate naturally) + pdfStream.Position = 0; + 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() + ); + } + + #endregion + + #region Private Helpers + /// /// Detects if PDF is encrypted by scanning for /Encrypt keyword. /// @@ -252,4 +318,6 @@ public class DevExpressPdfProcessor : IPdfProcessor searchStart = embeddedFilesIndex + 1; } } + + #endregion }