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() ); } #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; } } #endregion }