Files
TekH 0e88b349d7 Rebrand project: DocumentOperator to DocumentService
This commit implements a complete rebranding of the project:
- Updated all namespaces from `DocumentOperator` to `DocumentService`.
- Renamed file paths, embedded resources, and test data references.
- Updated configuration keys, logging paths, and Redis instance names.
- Revised documentation to reflect the new project name.
- Modified project and solution files to align with the new structure.
- Updated class names, DTOs, commands, queries, and handlers.
- Adjusted middleware, controllers, and API endpoints.
- Updated Swagger metadata and API titles to `DocumentService API`.
- Refactored test namespaces, resource paths, and embedded resources.
- Updated build and deployment configurations for the new name.
- Replaced all references to `DocumentOperator` in comments and literals.

These changes ensure consistency across the codebase and documentation.
2026-07-30 14:02:56 +02:00

1206 lines
48 KiB
C#

using DevExpress.Pdf;
using DevExpress.Drawing;
using System.Drawing;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
namespace DocumentService.Infrastructure.Services.PdfProcessing;
/// <summary>
/// PDF processor implementation using DevExpress.Pdf library.
/// Handles PDF validation, metadata extraction, and attachment operations.
/// </summary>
public class DevExpressPdfProcessor : IPdfProcessor
{
#region PDF Validation
/// <summary>
/// Validates a PDF document and returns metadata.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>PDF metadata (page count, file size, version, etc.)</returns>
/// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
public async Task<Application.Common.DTOs.PdfMetadata> 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
);
}
/// <summary>
/// Validates a PDF/A document and checks conformance level.
/// </summary>
/// <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>
/// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
public async Task<PdfAMetadata> 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<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");
}
// 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");
}
// 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<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()
);
}
/// <summary>
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
/// Uses DevExpress PdfDocument.FileAttachments to retrieve attachment data.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>ZIP archive byte array containing all extracted attachments</returns>
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
/// <exception cref="NotFoundException">Thrown when PDF contains no attachments</exception>
public async Task<byte[]> 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<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? 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
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </summary>
public async Task<byte[]> 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;
}
}
/// <summary>
/// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor
/// </summary>
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
/// <summary>
/// Detects if PDF is encrypted by scanning for /Encrypt keyword.
/// </summary>
private static bool DetectEncryption(byte[] pdfBytes)
{
string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes);
return pdfText.Contains("/Encrypt", StringComparison.Ordinal);
}
/// <summary>
/// Detects PDF/A conformance level by scanning PDF metadata.
/// PDF/A documents contain an XMP metadata stream with pdfaid:conformance and pdfaid:part.
/// </summary>
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: <pdfaid:part>1</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>
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+)</pdfaid:part");
var conformanceMatch = System.Text.RegularExpressions.Regex.Match(pdfText, @"pdfaid:conformance>([ABU])</pdfaid:conformance");
if (partMatch.Success && conformanceMatch.Success)
{
string part = partMatch.Groups[1].Value; // "1", "2", "3"
string conformance = conformanceMatch.Groups[1].Value; // "A", "B", "U"
string pdfaVersion = $"PDF/A-{part}{conformance.ToLower()}";
return (true, pdfaVersion);
}
// Found pdfaid:part but couldn't parse details
return (true, "PDF/A (unknown level)");
}
return (false, null);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="pdfBytes">PDF raw bytes</param>
/// <returns>Tuple: (hasAttachments, attachmentCount)</returns>
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;
}
}
/// <summary>
/// Parses page range string into list of zero-based page indices.
/// </summary>
/// <param name="pageRange">Page range string (e.g., "1-3,5" or "1,3,5")</param>
/// <param name="totalPages">Total page count in PDF (for validation)</param>
/// <returns>List of zero-based page indices</returns>
/// <exception cref="BadRequestException">Invalid format or page number out of range</exception>
private static List<int> ParsePageRange(string pageRange, int totalPages)
{
var pageIndices = new HashSet<int>(); // 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<byte[]> 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)
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
public async Task<byte[]> 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)
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
public async Task<byte[]> 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.");
}
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
public async Task<byte[]> 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
}