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.Pdf;
|
||||||
|
using DevExpress.Drawing;
|
||||||
|
using System.Drawing;
|
||||||
using DocumentOperator.Application.Common.DTOs;
|
using DocumentOperator.Application.Common.DTOs;
|
||||||
using DocumentOperator.Application.Common.Interfaces;
|
using DocumentOperator.Application.Common.Interfaces;
|
||||||
using DocumentOperator.Domain.Common.Exceptions;
|
using DocumentOperator.Domain.Common.Exceptions;
|
||||||
@@ -403,7 +405,8 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
|||||||
string? content = null,
|
string? content = null,
|
||||||
string? author = null,
|
string? author = null,
|
||||||
string? color = 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
|
// 1. Input validation
|
||||||
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
||||||
@@ -442,38 +445,56 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
|||||||
if (pageNumber > pageCount)
|
if (pageNumber > pageCount)
|
||||||
throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({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];
|
var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1];
|
||||||
|
|
||||||
// 5. Create annotation rectangle
|
// 6. Create annotation rectangle from converted coordinates
|
||||||
var pdfRectangle = new PdfRectangle(rectangle.X1, rectangle.Y1, rectangle.X2, rectangle.Y2);
|
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
|
PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup
|
||||||
? new PdfRGBColor(1.0, 1.0, 0) // Yellow
|
? new PdfRGBColor(1.0, 1.0, 0) // Yellow
|
||||||
: new PdfRGBColor(1.0, 0, 0)); // Red
|
: new PdfRGBColor(1.0, 0, 0)); // Red
|
||||||
|
|
||||||
// 7. Add annotation based on type
|
// 8. Add annotation based on type
|
||||||
switch (annotationType)
|
switch (annotationType)
|
||||||
{
|
{
|
||||||
case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
|
case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
|
||||||
AddTextMarkupAnnotation(pageFacade, pdfRectangle, textMarkupStyle!.Value, content, author, annotationColor);
|
AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Domain.Models.ValueObjects.AnnotationType.FreeText:
|
case Domain.Models.ValueObjects.AnnotationType.FreeText:
|
||||||
AddFreeTextAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor);
|
AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Domain.Models.ValueObjects.AnnotationType.StickyNote:
|
case Domain.Models.ValueObjects.AnnotationType.StickyNote:
|
||||||
AddStickyNoteAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor);
|
AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Domain.Models.ValueObjects.AnnotationType.Circle:
|
case Domain.Models.ValueObjects.AnnotationType.Circle:
|
||||||
AddCircleAnnotation(pageFacade, pdfRectangle, content, author, annotationColor);
|
AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Domain.Models.ValueObjects.AnnotationType.Square:
|
case Domain.Models.ValueObjects.AnnotationType.Square:
|
||||||
AddSquareAnnotation(pageFacade, pdfRectangle, content, author, annotationColor);
|
AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -814,4 +835,262 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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