feat(annotation): Implement coordinate system conversion in DevExpressPdfProcessor
- Add Y-axis conversion for TopLeft origin (bottomLeftY = pageHeight - topLeftY) - Origin parameter support in AddAnnotationAsync - Preserves existing BottomLeft behavior as default
This commit is contained in:
@@ -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<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user