Updated project and solution files to rename and restructure `DocumentOperator` to `DocumentService`. Modified project references in `DocumentService.API.csproj`, `DocumentService.Application.csproj`, and `DocumentService.Infrastructure.csproj` to reflect the new naming convention. Updated `DocumentService.sln` to remove old project references, add new ones, and adjust configuration and nested project mappings accordingly. This change aligns the project structure with the new naming convention.
1306 lines
52 KiB
C#
1306 lines
52 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.PdfValidationResult> 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);
|
||
|
||
// Check encryption (scan PDF raw data for /Encrypt keyword)
|
||
bool isEncrypted = DetectEncryption(pdfBytes);
|
||
|
||
// 5. Create and return PdfValidationResult
|
||
return new Application.Common.DTOs.PdfValidationResult(
|
||
PageCount: pageCount,
|
||
FileSizeBytes: pdfBytes.Length,
|
||
FileSizeMB: pdfBytes.Length / 1024.0 / 1024.0,
|
||
PdfVersion: pdfVersion,
|
||
HasAttachments: hasAttachments,
|
||
AttachmentCount: attachmentCount,
|
||
IsEncrypted: isEncrypted
|
||
);
|
||
}
|
||
|
||
/// <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<PdfAValidationResult> 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 PdfAValidationResult
|
||
return new PdfAValidationResult
|
||
{
|
||
IsValid = isValid,
|
||
PdfVersion = pdfVersion,
|
||
PageCount = pageCount,
|
||
FileSize = 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 a PDF is encrypted by scanning the PDF trailer/xref section for an /Encrypt dictionary reference.
|
||
///
|
||
/// Why NOT use Encoding.ASCII.GetString():
|
||
/// - Binary PDFs contain bytes 0x80–0xFF which ASCII decoding maps to '?' (0x3F), corrupting data.
|
||
/// - A string search on corrupted data can produce both false positives and false negatives.
|
||
///
|
||
/// This implementation searches the raw bytes directly, which is safe for all PDF variants
|
||
/// (binary, linearized, cross-reference stream PDFs).
|
||
///
|
||
/// According to the PDF specification (ISO 32000), the /Encrypt entry only appears in the
|
||
/// document's trailer dictionary. Searching only the last ~2KB (where the trailer lives)
|
||
/// eliminates false positives from document content that might contain the literal text "/Encrypt".
|
||
///
|
||
/// Limitation: Cross-reference stream PDFs (PDF 1.5+) embed the trailer inline in a compressed
|
||
/// stream object. In those cases the raw /Encrypt bytes may not appear in the trailer section,
|
||
/// so we fall back to searching the entire file. This is still byte-level and therefore safe.
|
||
/// </summary>
|
||
private static bool DetectEncryption(byte[] pdfBytes)
|
||
{
|
||
// The byte sequence we are looking for (ASCII, safe because PDF keywords are always ASCII)
|
||
ReadOnlySpan<byte> encryptKeyword = "/Encrypt"u8;
|
||
|
||
// Strategy 1: Search only the last 2 KB (trailer area).
|
||
// The PDF trailer dictionary is always near the end of the file and contains
|
||
// /Encrypt only when the document is encrypted. This avoids false positives
|
||
// from document content.
|
||
int trailerSearchStart = Math.Max(0, pdfBytes.Length - 2048);
|
||
ReadOnlySpan<byte> trailerRegion = pdfBytes.AsSpan(trailerSearchStart);
|
||
|
||
if (ContainsByteSequence(trailerRegion, encryptKeyword))
|
||
return true;
|
||
|
||
// Strategy 2: Some PDF generators (e.g. linearized PDFs) place the trailer
|
||
// at the beginning of the file as well. Search the first 2 KB.
|
||
int headerSearchEnd = Math.Min(2048, pdfBytes.Length);
|
||
ReadOnlySpan<byte> headerRegion = pdfBytes.AsSpan(0, headerSearchEnd);
|
||
|
||
if (ContainsByteSequence(headerRegion, encryptKeyword))
|
||
return true;
|
||
|
||
// Strategy 3: Full-file fallback for cross-reference stream PDFs (PDF 1.5+).
|
||
// In these files the xref/trailer is a compressed stream object that can appear
|
||
// anywhere. We scan the entire file but require that /Encrypt is followed by
|
||
// a space or '<' (start of dictionary), which rules out arbitrary text matches.
|
||
ReadOnlySpan<byte> fullFile = pdfBytes.AsSpan();
|
||
int idx = 0;
|
||
while (idx <= pdfBytes.Length - encryptKeyword.Length)
|
||
{
|
||
int found = IndexOf(fullFile.Slice(idx), encryptKeyword);
|
||
if (found == -1)
|
||
break;
|
||
|
||
int absolutePos = idx + found;
|
||
int afterKeyword = absolutePos + encryptKeyword.Length;
|
||
|
||
// Verify the character after /Encrypt is a PDF structural character,
|
||
// not part of a longer token (e.g. "/Encrypted" should not match).
|
||
if (afterKeyword < pdfBytes.Length)
|
||
{
|
||
byte next = pdfBytes[afterKeyword];
|
||
// Valid PDF token terminators: space (0x20), newline, tab, '<', '/', '['
|
||
if (next == 0x20 || next == 0x0A || next == 0x0D || next == 0x09 ||
|
||
next == 0x3C || next == 0x2F || next == 0x5B)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// /Encrypt is the very last token in the file
|
||
return true;
|
||
}
|
||
|
||
idx = absolutePos + 1;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the index of <paramref name="needle"/> within <paramref name="haystack"/>,
|
||
/// or -1 if not found. Operates entirely on raw bytes — no string allocation.
|
||
/// </summary>
|
||
private static int IndexOf(ReadOnlySpan<byte> haystack, ReadOnlySpan<byte> needle)
|
||
{
|
||
if (needle.IsEmpty) return 0;
|
||
if (haystack.Length < needle.Length) return -1;
|
||
|
||
for (int i = 0; i <= haystack.Length - needle.Length; i++)
|
||
{
|
||
if (haystack.Slice(i, needle.Length).SequenceEqual(needle))
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true if <paramref name="haystack"/> contains <paramref name="needle"/> as a byte subsequence.
|
||
/// </summary>
|
||
private static bool ContainsByteSequence(ReadOnlySpan<byte> haystack, ReadOnlySpan<byte> needle)
|
||
=> IndexOf(haystack, needle) >= 0;
|
||
|
||
/// <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
|
||
}
|