refactor: Update DevExpressPdfProcessor to use Stream parameters

- All 3 methods (ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync) now accept Stream
- Add ArgumentNullException.ThrowIfNull() checks for null streams
- Implement fast-path optimization: reuse MemoryStream buffer when possible
- Implement slow-path fallback: copy stream to byte[] for DevExpress API compatibility
- Fix namespace collision: use fully qualified Application.Common.DTOs.PdfMetadata
This commit is contained in:
2026-07-20 11:55:17 +02:00
parent 73a7afe257
commit 1de781748b

View File

@@ -1,150 +1,216 @@
using DevExpress.Pdf; using DevExpress.Pdf;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions; using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentOperator.Infrastructure.Services.PdfProcessing; namespace DocumentOperator.Infrastructure.Services.PdfProcessing;
/// <summary> /// <summary>
/// PDF processor implementation using DevExpress.Pdf library. /// PDF processor implementation using DevExpress.Pdf library.
/// Handles PDF validation and metadata extraction. /// Handles PDF validation, metadata extraction, and attachment operations.
/// </summary> /// </summary>
public class DevExpressPdfProcessor : IPdfProcessor public class DevExpressPdfProcessor : IPdfProcessor
{ {
#region PDF Validation
/// <summary> /// <summary>
/// Validates a PDF document and returns metadata. /// Validates a PDF document and returns metadata.
/// </summary> /// </summary>
/// <param name="pdfBytes">PDF content as byte array</param> /// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>PDF metadata (page count, file size, version, etc.)</returns> /// <returns>PDF metadata (page count, file size, version, etc.)</returns>
/// <exception cref="PdfProcessingException">Thrown when PDF is invalid or null</exception> /// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
public async Task<Domain.Models.ValueObjects.PdfMetadata> ValidateAsync(byte[] pdfBytes) public async Task<Application.Common.DTOs.PdfMetadata> ValidateAsync(Stream pdfStream)
{ {
// 1. Input Validation (Defensive Programming) // 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
// (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 // 3. Load PDF with DevExpress Document API
{ pdfStream.Position = 0;
// 2. Load PDF with DevExpress Document API (PdfDocumentProcessor) using var processor = new PdfDocumentProcessor();
using var processor = new PdfDocumentProcessor(); processor.LoadDocument(pdfStream);
processor.LoadDocument(new MemoryStream(pdfBytes));
// 3. Extract metadata // 4. Extract metadata
var document = processor.Document; var document = processor.Document;
int pageCount = document.Pages.Count; int pageCount = document.Pages.Count;
string pdfVersion = document.Version.ToString(); // z.B. "1.4", "1.7" string pdfVersion = document.Version.ToString(); // e.g., "1.4", "1.7"
// Attachments (embedded files) // Attachments (embedded files)
// DevExpress PdfDocument API doesn't expose EmbeddedFiles directly. // 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. // We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count.
var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes); var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes);
// 4. Create and return PdfMetadata Value Object // 5. Create and return PdfMetadata DTO
return new Domain.Models.ValueObjects.PdfMetadata( return new Application.Common.DTOs.PdfMetadata(
pageCount: pageCount, pageCount: pageCount,
fileSizeBytes: pdfBytes.Length, fileSizeBytes: pdfBytes.Length,
pdfVersion: pdfVersion, pdfVersion: pdfVersion,
hasAttachments: hasAttachments, hasAttachments: hasAttachments,
attachmentCount: attachmentCount 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);
}
} }
/// <summary> /// <summary>
/// Validates a PDF/A document and checks conformance level. /// Validates a PDF/A document and checks conformance level.
/// </summary> /// </summary>
/// <param name="pdfBytes">PDF content as byte array</param> /// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns> /// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
/// <exception cref="PdfProcessingException">Thrown when PDF is invalid or null</exception> /// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
public async Task<PdfAMetadata> ValidatePdfAAsync(byte[] pdfBytes) public async Task<PdfAMetadata> ValidatePdfAAsync(Stream pdfStream)
{ {
// 1. Input Validation // 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<string>();
var warnings = new List<string>();
// If encrypted, PDF/A compliance is not possible
if (encrypted && isPdfACompliant)
{ {
// 2. Load PDF with DevExpress Document API errors.Add("PDF/A documents cannot be encrypted");
using var processor = new PdfDocumentProcessor(); isPdfACompliant = false;
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<string>();
var warnings = new List<string>();
// 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
);
} }
catch (Exception ex) when (ex is not PdfProcessingException)
// Basic PDF/A validation checks
if (isPdfACompliant)
{ {
throw new PdfProcessingException( // Add generic warning for manual verification
$"Failed to validate PDF/A: {ex.Message}", warnings.Add("Manual verification recommended: All fonts must be embedded");
ex); 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
/// <summary>
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
/// Uses DevExpress PdfDocument.FileAttachments collection to retrieve attachment details.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>Attachment information including count, file names, MIME types, and sizes</returns>
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
public async Task<AttachmentInfo> 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<PdfFileAttachment>)
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
/// <summary> /// <summary>
/// Detects if PDF is encrypted by scanning for /Encrypt keyword. /// Detects if PDF is encrypted by scanning for /Encrypt keyword.
/// </summary> /// </summary>
@@ -252,4 +318,6 @@ public class DevExpressPdfProcessor : IPdfProcessor
searchStart = embeddedFilesIndex + 1; searchStart = embeddedFilesIndex + 1;
} }
} }
#endregion
} }