Add PDF attachment and PDF/A conversion features
Introduced endpoints for embedding attachments in PDFs and converting between standard PDFs and PDF/A formats. Added `PdfAttachmentController` and `PdfConversionController` with multipart/form-data and Base64-based support. Implemented commands, handlers, and validators for these operations. Extended `IPdfProcessor` with methods for adding attachments and PDF/A conversion. Partially implemented functionality in `DevExpressPdfProcessor`, including `ConvertFromPdfAAsync`. Added `attachment.xml` and `withoutAttachment.pdf` as resources for testing. Marked endpoints as `[Obsolete]` to indicate incomplete implementation. Improved validation and error handling for commands.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.AddAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add one or more attachments to a PDF document (supports PDF/A-3)
|
||||
/// </summary>
|
||||
public record AddAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachments to embed (filename, content, optional MIME type)
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AttachmentFile> Attachments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file to be attached to a PDF
|
||||
/// </summary>
|
||||
public record AttachmentFile
|
||||
{
|
||||
/// <summary>
|
||||
/// File name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content as byte array
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type (optional, e.g., "application/xml", "application/pdf")
|
||||
/// If not provided, will be inferred from file extension
|
||||
/// </summary>
|
||||
public string? MimeType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<AddAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert to tuple list for IPdfProcessor
|
||||
var attachmentTuples = request.Attachments
|
||||
.Select(a => (a.FileName, a.Content, a.MimeType))
|
||||
.ToList();
|
||||
|
||||
return await pdfProcessor.AddAttachmentsAsync(request.PdfStream, attachmentTuples);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandValidator : AbstractValidator<AddAttachmentsCommand>
|
||||
{
|
||||
public AddAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Attachments)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one attachment is required");
|
||||
|
||||
RuleForEach(x => x.Attachments).ChildRules(attachment =>
|
||||
{
|
||||
attachment.RuleFor(a => a.FileName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment file name is required");
|
||||
|
||||
attachment.RuleFor(a => a.Content)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment content is required");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -145,4 +145,47 @@ public interface IPdfProcessor
|
||||
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
|
||||
/// <returns>PDF with embedded attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid, or attachments list is empty
|
||||
/// </exception>
|
||||
Task<byte[]> AddAttachmentsAsync(
|
||||
Stream pdfStream,
|
||||
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
|
||||
/// <returns>PDF/A compliant document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Standard PDF document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.ConvertFromPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a PDF/A document to a standard PDF (removes PDF/A restrictions)
|
||||
/// </summary>
|
||||
public record ConvertFromPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF/A document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertFromPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertFromPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertFromPdfAAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandValidator : AbstractValidator<ConvertFromPdfACommand>
|
||||
{
|
||||
public ConvertFromPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.ConvertToPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a standard PDF to PDF/A format
|
||||
/// </summary>
|
||||
public record ConvertToPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")
|
||||
/// </summary>
|
||||
public required string PdfALevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertToPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertToPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertToPdfAAsync(request.PdfStream, request.PdfALevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandValidator : AbstractValidator<ConvertToPdfACommand>
|
||||
{
|
||||
private static readonly string[] ValidPdfALevels =
|
||||
{
|
||||
"PDF/A-1b", "PDF/A-1a",
|
||||
"PDF/A-2b", "PDF/A-2u", "PDF/A-2a",
|
||||
"PDF/A-3b", "PDF/A-3u", "PDF/A-3a"
|
||||
};
|
||||
|
||||
public ConvertToPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PdfALevel)
|
||||
.NotEmpty()
|
||||
.WithMessage("PDF/A level is required")
|
||||
.Must(level => ValidPdfALevels.Contains(level, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage($"Invalid PDF/A level. Valid values: {string.Join(", ", ValidPdfALevels)}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user