diff --git a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs index 59df11c..cca97fc 100644 --- a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs +++ b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs @@ -1,4 +1,6 @@ using DevExpress.Pdf; +using DevExpress.Drawing; +using System.Drawing; using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Domain.Common.Exceptions; @@ -403,7 +405,8 @@ public class DevExpressPdfProcessor : IPdfProcessor string? content = null, string? author = null, string? color = null, - Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = 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)); @@ -442,38 +445,56 @@ public class DevExpressPdfProcessor : IPdfProcessor if (pageNumber > pageCount) throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})"); - // 4. Get page facade (zero-based index) + // 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]; - // 5. Create annotation rectangle - var pdfRectangle = new PdfRectangle(rectangle.X1, rectangle.Y1, rectangle.X2, rectangle.Y2); + // 6. Create annotation rectangle from converted coordinates + var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2); - // 6. Parse color (default to yellow for highlights, red for others) + // 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 - // 7. Add annotation based on type + // 8. Add annotation based on type switch (annotationType) { case Domain.Models.ValueObjects.AnnotationType.TextMarkup: - AddTextMarkupAnnotation(pageFacade, pdfRectangle, textMarkupStyle!.Value, content, author, annotationColor); + AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.FreeText: - AddFreeTextAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); + AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.StickyNote: - AddStickyNoteAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); + AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Circle: - AddCircleAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); + AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor); break; case Domain.Models.ValueObjects.AnnotationType.Square: - AddSquareAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); + AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor); break; default: @@ -814,4 +835,262 @@ public class DevExpressPdfProcessor : IPdfProcessor } #endregion + + #region AddStampAsync + + public async Task 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 }