Consolidated DTOs under `DocumentService.Application.Common.DTOs.Requests` to centralize and align them with the application layer. Introduced `PdfBase64RequestBase` to encapsulate shared properties, reducing redundancy across request DTOs. Updated controllers, clients, and tests to use the new DTO structure. Removed redundant DTOs and cleaned up unused namespaces and comments. Improved documentation and ensured consistent naming conventions across the codebase.
512 lines
20 KiB
C#
512 lines
20 KiB
C#
using DocumentService.Application.AddAnnotation;
|
|
using DocumentService.Application.AddStamp;
|
|
using DocumentService.Application.Common.DTOs.Requests;
|
|
using DocumentService.Application.MergePdfs;
|
|
using DocumentService.Domain.Common.Exceptions;
|
|
using DocumentService.Domain.Models.ValueObjects;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace DocumentService.API.Controllers;
|
|
|
|
/// <summary>
|
|
/// Controller for PDF operations (merge, stamp, annotate).
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/pdf/operations")]
|
|
public class PdfOperationsController(IMediator mediator) : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// Merges multiple PDF files into a single PDF.
|
|
/// Supports multipart/form-data file upload.
|
|
/// </summary>
|
|
/// <param name="files">PDF files to merge (minimum 2 required)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Merged PDF file</returns>
|
|
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
|
/// <response code="400">Invalid input (fewer than 2 files, corrupted PDF)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("merge", Name = "MergeFromFiles")]
|
|
[Consumes("multipart/form-data")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> MergeFromFiles(
|
|
[FromForm] List<IFormFile> files,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Convert IFormFile[] to Stream[] (use OpenReadStream directly - no buffering)
|
|
var streams = files.Select(f => f.OpenReadStream()).ToList();
|
|
|
|
// Send command to MediatR (no page ranges for now - multipart binding is complex)
|
|
var command = new MergePdfsCommand
|
|
{
|
|
PdfStreams = streams,
|
|
PageRanges = null
|
|
};
|
|
|
|
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
|
|
|
// Return merged PDF
|
|
return File(mergedPdf, "application/pdf", "merged.pdf");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Merges multiple PDF files into a single PDF.
|
|
/// Supports Base64-encoded PDFs via JSON payload.
|
|
/// </summary>
|
|
/// <param name="request">Request containing Base64-encoded PDFs and optional page ranges</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Merged PDF file</returns>
|
|
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
|
/// <response code="400">Invalid input (Base64 format error, fewer than 2 files, corrupted PDF, invalid page range)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("merge", Name = "MergeFromBase64")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> MergeFromBase64(
|
|
[FromBody] MergePdfsBase64Request request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Convert Base64[] to MemoryStream[]
|
|
List<Stream> streams = [];
|
|
try
|
|
{
|
|
foreach (var base64Pdf in request.Base64Pdfs)
|
|
{
|
|
byte[] pdfBytes = Convert.FromBase64String(base64Pdf);
|
|
streams.Add(new MemoryStream(pdfBytes));
|
|
}
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
// Dispose opened streams on error
|
|
foreach (var stream in streams) stream.Dispose();
|
|
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
|
}
|
|
|
|
var command = new MergePdfsCommand
|
|
{
|
|
PdfStreams = streams,
|
|
PageRanges = request.PageRanges
|
|
};
|
|
|
|
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
|
|
|
// Cleanup streams (important for MemoryStreams we created)
|
|
foreach (var stream in streams) stream.Dispose();
|
|
|
|
return File(mergedPdf, "application/pdf", "merged.pdf");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an annotation to a PDF document.
|
|
/// Supports multipart/form-data file upload.
|
|
/// </summary>
|
|
/// <param name="request">Multipart form data containing PDF file and annotation parameters</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Annotated PDF file</returns>
|
|
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
|
/// <response code="400">Invalid input (invalid page number, missing required parameters)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("annotate", Name = "AnnotateFromFile")]
|
|
[Consumes("multipart/form-data")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> AnnotateFromFile(
|
|
[FromForm] AddAnnotationMultipartRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Calculate X2, Y2 from Width/Height if provided
|
|
double x2 = request.X2 ?? request.X1 + (request.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
|
double y2 = request.Y2 ?? request.Y1 + (request.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
|
|
|
var command = new AddAnnotationCommand
|
|
{
|
|
PdfStream = request.File.OpenReadStream(),
|
|
AnnotationType = request.AnnotationType,
|
|
PageNumber = request.PageNumber,
|
|
Rectangle = (request.X1, request.Y1, x2, y2),
|
|
Content = request.Content,
|
|
Author = request.Author,
|
|
Color = request.Color,
|
|
TextMarkupStyle = request.TextMarkupStyle,
|
|
Origin = request.Origin
|
|
};
|
|
|
|
byte[] annotatedPdf = await mediator.Send(command, cancellationToken);
|
|
|
|
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an annotation to a PDF document.
|
|
/// Supports Base64-encoded PDF via JSON payload.
|
|
/// </summary>
|
|
/// <param name="command">Command containing all annotation parameters (including Base64 PDF)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Annotated PDF file</returns>
|
|
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
|
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("annotate", Name = "AnnotateFromBase64")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> AnnotateFromBase64(
|
|
[FromBody] AddAnnotationBase64Request command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Convert Base64 to MemoryStream
|
|
Stream pdfStream;
|
|
try
|
|
{
|
|
byte[] pdfBytes = Convert.FromBase64String(command.Base64Pdf);
|
|
pdfStream = new MemoryStream(pdfBytes);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
|
}
|
|
|
|
// Calculate X2, Y2 from Width/Height if provided
|
|
double x2 = command.X2 ?? command.X1 + (command.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
|
double y2 = command.Y2 ?? command.Y1 + (command.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
|
|
|
var annotationCommand = new AddAnnotationCommand
|
|
{
|
|
PdfStream = pdfStream,
|
|
AnnotationType = command.AnnotationType,
|
|
PageNumber = command.PageNumber,
|
|
Rectangle = (command.X1, command.Y1, x2, y2),
|
|
Content = command.Content,
|
|
Author = command.Author,
|
|
Color = command.Color,
|
|
TextMarkupStyle = command.TextMarkupStyle,
|
|
Origin = command.Origin
|
|
};
|
|
|
|
byte[] annotatedPdf = await mediator.Send(annotationCommand, cancellationToken);
|
|
|
|
// Cleanup stream
|
|
pdfStream.Dispose();
|
|
|
|
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
|
/// Supports multipart/form-data file upload.
|
|
/// </summary>
|
|
/// <param name="request">Multipart form data containing PDF file and stamp parameters</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Stamped PDF file</returns>
|
|
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
|
/// <response code="400">Invalid input (invalid page number, missing required parameters, invalid image format)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("stamp", Name = "AddStampFromFile")]
|
|
[Consumes("multipart/form-data")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> AddStampFromFile(
|
|
[FromForm] AddStampMultipartRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Convert ImageFile to byte[] if provided
|
|
byte[]? imageBytes = null;
|
|
if (request.ImageFile != null)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await request.ImageFile.CopyToAsync(ms, cancellationToken);
|
|
imageBytes = ms.ToArray();
|
|
}
|
|
|
|
var command = new AddStampCommand
|
|
{
|
|
PdfStream = request.File.OpenReadStream(),
|
|
StampType = request.StampType,
|
|
PageNumbers = request.PageNumbers,
|
|
Position = (request.X, request.Y),
|
|
Size = request.Width.HasValue && request.Height.HasValue
|
|
? (request.Width.Value, request.Height.Value)
|
|
: null,
|
|
Origin = request.Origin,
|
|
Text = request.Text,
|
|
FontName = request.FontName,
|
|
FontSize = request.FontSize,
|
|
Color = request.Color,
|
|
Opacity = request.Opacity,
|
|
Rotation = request.Rotation,
|
|
Placement = request.Placement,
|
|
ImageBytes = imageBytes,
|
|
PredefinedType = request.PredefinedType
|
|
};
|
|
|
|
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
|
|
|
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
|
/// Supports Base64-encoded PDF and image via JSON payload.
|
|
/// </summary>
|
|
/// <param name="request">Request containing Base64-encoded PDF, stamp parameters, and optional Base64 image</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Stamped PDF file</returns>
|
|
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
|
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
|
/// <response code="500">Internal server error during PDF processing</response>
|
|
[HttpPost("stamp", Name = "AddStampFromBase64")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> AddStampFromBase64(
|
|
[FromBody] AddStampBase64Request request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Convert Base64 PDF to MemoryStream
|
|
Stream pdfStream;
|
|
try
|
|
{
|
|
byte[] pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
|
pdfStream = new MemoryStream(pdfBytes);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
|
}
|
|
|
|
// Convert Base64 image to byte[] if provided
|
|
byte[]? imageBytes = null;
|
|
if (!string.IsNullOrWhiteSpace(request.Base64Image))
|
|
{
|
|
try
|
|
{
|
|
imageBytes = Convert.FromBase64String(request.Base64Image);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
pdfStream.Dispose();
|
|
throw new BadRequestException("Invalid Base64 image format: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
var command = new AddStampCommand
|
|
{
|
|
PdfStream = pdfStream,
|
|
StampType = request.StampType,
|
|
PageNumbers = request.PageNumbers,
|
|
Position = (request.X, request.Y),
|
|
Size = request.Width.HasValue && request.Height.HasValue
|
|
? (request.Width.Value, request.Height.Value)
|
|
: null,
|
|
Origin = request.Origin,
|
|
Text = request.Text,
|
|
FontName = request.FontName,
|
|
FontSize = request.FontSize,
|
|
Color = request.Color,
|
|
Opacity = request.Opacity,
|
|
Rotation = request.Rotation,
|
|
Placement = request.Placement,
|
|
ImageBytes = imageBytes,
|
|
PredefinedType = request.PredefinedType
|
|
};
|
|
|
|
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
|
|
|
// Cleanup stream
|
|
pdfStream.Dispose();
|
|
|
|
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request DTO for multipart/form-data annotation operation
|
|
/// </summary>
|
|
public class AddAnnotationMultipartRequest
|
|
{
|
|
/// <summary>
|
|
/// PDF file to annotate
|
|
/// </summary>
|
|
public required IFormFile File { get; set; }
|
|
|
|
/// <summary>
|
|
/// Type of annotation (TextMarkup, FreeText, StickyNote, Circle, Square)
|
|
/// </summary>
|
|
public required AnnotationType AnnotationType { get; set; }
|
|
|
|
/// <summary>
|
|
/// Target page number (1-indexed)
|
|
/// </summary>
|
|
public required int PageNumber { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle X1 coordinate (left)
|
|
/// </summary>
|
|
public required double X1 { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
|
|
/// </summary>
|
|
public required double Y1 { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle X2 coordinate (right). Optional if Width is provided.
|
|
/// </summary>
|
|
public double? X2 { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
|
|
/// </summary>
|
|
public double? Y2 { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
|
|
/// </summary>
|
|
public double? Width { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
|
|
/// </summary>
|
|
public double? Height { get; set; }
|
|
|
|
/// <summary>
|
|
/// Annotation content (required for FreeText/StickyNote)
|
|
/// </summary>
|
|
public string? Content { get; set; }
|
|
|
|
/// <summary>
|
|
/// Author name (optional)
|
|
/// </summary>
|
|
public string? Author { get; set; }
|
|
|
|
/// <summary>
|
|
/// Hex color (6 digits, e.g., "FF0000" for red)
|
|
/// </summary>
|
|
public string? Color { get; set; }
|
|
|
|
/// <summary>
|
|
/// Markup style (Highlight/Underline/Strikeout, required for TextMarkup)
|
|
/// </summary>
|
|
public TextMarkupStyle? TextMarkupStyle { get; set; }
|
|
|
|
/// <summary>
|
|
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
|
/// </summary>
|
|
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request DTO for multipart/form-data stamp operation
|
|
/// </summary>
|
|
public class AddStampMultipartRequest
|
|
{
|
|
/// <summary>
|
|
/// PDF file to stamp
|
|
/// </summary>
|
|
public required IFormFile File { get; set; }
|
|
|
|
/// <summary>
|
|
/// Type of stamp (Text, Image, or Predefined)
|
|
/// </summary>
|
|
public required StampType StampType { get; set; }
|
|
|
|
/// <summary>
|
|
/// Target page numbers (1-indexed). Null or empty = all pages.
|
|
/// </summary>
|
|
/// <example>[1, 3, 5]</example>
|
|
public int[]? PageNumbers { get; set; }
|
|
|
|
/// <summary>
|
|
/// Stamp position X coordinate
|
|
/// </summary>
|
|
/// <example>100.0</example>
|
|
public required double X { get; set; }
|
|
|
|
/// <summary>
|
|
/// Stamp position Y coordinate
|
|
/// </summary>
|
|
/// <example>100.0</example>
|
|
public required double Y { get; set; }
|
|
|
|
/// <summary>
|
|
/// Stamp width (optional, auto-size for images if not specified)
|
|
/// </summary>
|
|
/// <example>200.0</example>
|
|
public double? Width { get; set; }
|
|
|
|
/// <summary>
|
|
/// Stamp height (optional, auto-size for images if not specified)
|
|
/// </summary>
|
|
/// <example>50.0</example>
|
|
public double? Height { get; set; }
|
|
|
|
/// <summary>
|
|
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
|
/// </summary>
|
|
/// <example>BottomLeft</example>
|
|
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
|
|
|
/// <summary>
|
|
/// Text content (required for Text stamps)
|
|
/// </summary>
|
|
/// <example>"CONFIDENTIAL"</example>
|
|
public string? Text { get; set; }
|
|
|
|
/// <summary>
|
|
/// Font name (default: Arial)
|
|
/// </summary>
|
|
/// <example>"Arial"</example>
|
|
public string? FontName { get; set; }
|
|
|
|
/// <summary>
|
|
/// Font size in points (default: 12)
|
|
/// </summary>
|
|
/// <example>24.0</example>
|
|
public double? FontSize { get; set; }
|
|
|
|
/// <summary>
|
|
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
|
|
/// </summary>
|
|
/// <example>"FF0000"</example>
|
|
public string? Color { get; set; }
|
|
|
|
/// <summary>
|
|
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
|
|
/// </summary>
|
|
/// <example>0.5</example>
|
|
public double? Opacity { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rotation angle in degrees (0-360, default: 0)
|
|
/// </summary>
|
|
/// <example>45.0</example>
|
|
public double? Rotation { get; set; }
|
|
|
|
/// <summary>
|
|
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
|
|
/// </summary>
|
|
/// <example>Foreground</example>
|
|
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
|
|
|
|
/// <summary>
|
|
/// Image file (required for Image stamps, PNG/JPEG)
|
|
/// </summary>
|
|
public IFormFile? ImageFile { get; set; }
|
|
|
|
/// <summary>
|
|
/// Predefined stamp type (required for Predefined stamps)
|
|
/// </summary>
|
|
/// <example>Confidential</example>
|
|
public PredefinedStampType? PredefinedType { get; set; }
|
|
}
|