Compare commits

...

47 Commits

Author SHA1 Message Date
2804993ea6 feat(api): Enable conditional Swagger/Scalar in production
Program.cs changes:
- Add Scalar.AspNetCore using
- Register SwaggerSettings with Options Pattern
- Pass IConfiguration to AddSwaggerDocumentation()
- Conditional middleware: Development OR EnableInProduction=true
- Add Swagger UI with custom endpoint configuration
- Add Scalar UI at /scalar/v1 (DeepSpace theme, C# HttpClient target)

Access URLs:
- Swagger UI: https://localhost:7186/swagger
- Scalar UI: https://localhost:7186/scalar/v1
- Serilog UI: https://localhost:7186/serilog-ui

Production behavior: Swagger+Scalar enabled by default (configurable)
2026-07-21 15:01:56 +02:00
06a9dc7385 chore(deps): Add Scalar.AspNetCore 1.2.58 package
- Modern Swagger UI alternative with better UX
- DeepSpace theme
- C# HttpClient code generation support
2026-07-21 15:01:46 +02:00
4b5c763f24 refactor(swagger): Read SwaggerConfiguration from appsettings
- Update AddSwaggerDocumentation to accept IConfiguration parameter
- Read SwaggerSettings from configuration (Title/Version/Description)
- Replace hardcoded values with dynamic settings
- Add Microsoft.Extensions.Options using for IOptions support
2026-07-21 15:01:38 +02:00
559c726118 feat(swagger): Configure Swagger settings in appsettings.json
- Add SwaggerSettings section with EnableInProduction: true
- Title: DocumentOperator API
- Version: v1
- Description: PDF document processing service using DevExpress
- Production Swagger enabled by default
2026-07-21 15:01:29 +02:00
1106a86ec3 feat(swagger): Add SwaggerSettings configuration class
- Add SwaggerSettings with EnableInProduction flag (default: true)
- Title, Version, Description configurable via appsettings.json
- Enables production Swagger access for debugging/testing
2026-07-21 15:01:22 +02:00
89436406ce Add PDF Stamp Operation feature with endpoints and tests
Implemented the "PDF Stamp Operation" feature, allowing users to add text, image, or predefined stamps to PDF documents.

- Added `FEATURE_7_PLAN.md` with detailed implementation plan.
- Introduced enums (`StampType`, `PredefinedStampType`, `StampPlacement`) in the domain layer.
- Added `AddStampAsync` method to `IPdfProcessor` interface.
- Implemented `AddStampAsync` in `DevExpressPdfProcessor` using DevExpress API.
- Created unit tests for `AddStampAsync` covering various scenarios.
- Added `AddStampCommand` in the application layer with validation rules.
- Created two new endpoints in `PdfOperationsController` for multipart and Base64 inputs.
- Added DTOs for handling endpoint requests.
- Wrote integration tests for endpoints to ensure correctness.
- Updated `AGENTS.md` and added DevExpress API references.
- Documented challenges, considerations, and estimated effort.

This commit completes the "PDF Stamp Operation" feature with full test coverage and documentation.
2026-07-21 14:45:30 +02:00
c96cbbc8d3 ee
Add IIS publish profile for Web Deploy packaging

Added `IISProfile.pubxml` to configure publishing via Web Deploy.
Set the publish method to `Package` and specified build settings
(`Release`, `Any CPU`). Configured the output package location,
enabled single-file packaging, and set the IIS app path to
`DocumentOperator.API`. Target framework updated to .NET 8.0.
2026-07-21 14:44:21 +02:00
9b85e55cd4 docs: Update AGENTS.md for Feature 7 completion + Serilog.UI
Updates:
- Mark stamp endpoint as DONE in Current Status table
- Add Serilog.UI packages to Key Libraries table
- Update test count (Feature 7 instead of Feature 6)
- Add Serilog UI URL to Build/Run section
- Document log viewer access at /serilog-ui
2026-07-21 14:39:40 +02:00
536413bafe feat(logging): Configure Serilog SQLite sink + UI
appsettings.json:
- Add Application:LogDirectory configuration
- Add Serilog WriteTo.SQLite sink (logs.db in LogDirectory)
- Logs table with UTC timestamps

Program.cs:
- Add Serilog.UI usings
- Configure Serilog.UI with SQLite provider
- Mount UI at /serilog-ui endpoint

Web-based log viewer accessible at: https://localhost:7186/serilog-ui
2026-07-21 14:39:32 +02:00
b37ccc8538 chore: Add Serilog.UI packages + versioning metadata
Packages:
- Serilog.Sinks.SQLite 7.0.0 (SQLite log persistence)
- Serilog.UI 3.2.0 (Web-based log viewer)
- Serilog.UI.SqliteProvider 1.1.0 (SQLite provider for Serilog.UI)

Versioning metadata:
- Version: 1.0.0
- Authors/Company: Digital Data GmbH
- Copyright: 2026
- Description: PDF document processing service using DevExpress
2026-07-21 14:39:24 +02:00
d0606f3605 feat(stamp): Add Application layer (AddStampCommand with handler/validator)
- Vertical slice: Command + Handler + Validator in single file
- Validation rules enforce StampType-specific required fields
- Text stamps: Text + FontName + FontSize required
- Image stamps: ImageBytes required
- Predefined stamps: PredefinedType required
- Supports Origin, Rotation, Opacity, Placement, Size parameters
2026-07-21 14:39:03 +02:00
c037ad8446 feat(stamp): Add IPdfProcessor.AddStampAsync interface
- Add AddStampAsync method signature to IPdfProcessor interface
- Parameters: pdfBytes, stampType, pages, position, text/image/predefined params
- Supports Origin (TopLeft/BottomLeft), Rotation, Opacity, Placement
2026-07-21 14:38:29 +02:00
e2bec710e0 feat(stamp): Add Domain value objects for stamp operations
- Add StampType enum (Text, Image, Predefined)
- Add PredefinedStampType enum (CONFIDENTIAL, APPROVED, DRAFT, etc.)
- Add StampPlacement enum (Foreground, Background)
- All enums in DocumentOperator.Domain namespace
2026-07-21 14:38:22 +02:00
61b11fc216 feat(annotation): Add Origin/Width/Height to API endpoints
- Update AddAnnotationFromFile/AddAnnotationFromBase64 endpoints
- Add Origin parameter to multipart/JSON request DTOs
- Add Width/Height as alternative to X2/Y2 in requests
- Calculate X2/Y2 from Width/Height if provided
- XML documentation updated with new parameters
2026-07-21 14:38:14 +02:00
c4ec0c2b48 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
2026-07-21 14:38:08 +02:00
eed9d46e19 feat(annotation): Add Origin/Width/Height parameters to AddAnnotationCommand
- Add Origin parameter (default: BottomLeft)
- Add Width/Height as alternatives to X2/Y2
- Validation: Either (X2+Y2) OR (Width+Height) required, not both
- FluentValidation rules enforce mutual exclusivity
2026-07-21 14:38:01 +02:00
c1bb3abeef feat(annotation): Add AnnotationOrigin value object
- Add AnnotationOrigin enum (BottomLeft/TopLeft)
- BottomLeft = PDF native coordinate system (default)
- TopLeft = UI-friendly coordinate system (requires Y-axis conversion)
- Shared with stamp operations for consistency
2026-07-21 14:37:54 +02:00
d72d41ec2d test(integration): Add 10 integration tests for annotation endpoints + update docs
Integration tests:
- 5 happy path tests (TextMarkup, FreeText, StickyNote, Circle, Square) with multipart + Base64 mix
- 5 validation error tests (invalid Base64, page number, missing content/style, invalid color)
- All existing 7 merge tests retained (now 17 total in PdfOperationsControllerTests)

Documentation updates (AGENTS.md):
- Update test count: 82 -> 101 passed, 7 skipped
- Update PdfOperationsController status: 1/N -> 2/3 endpoints (merge + annotate DONE, stamp TODO)
- Add test breakdown by feature (6 features listed)
- Update 'Run tests' section with Feature 6 mention

Test results: 101 PASSED, 7 SKIPPED, 0 FAILED
2026-07-21 12:28:59 +02:00
d4107f6f89 feat(api): Add annotation endpoints to PdfOperationsController
- Add POST /api/pdf/operations/annotate (multipart/form-data)
- Add POST /api/pdf/operations/annotate (application/json with Base64)
- Create AddAnnotationMultipartRequest DTO (wrapper for 10+ form parameters)
- Create AddAnnotationBase64Command DTO (Base64 PDF + annotation parameters)
- Add unique operation names (AnnotateFromFile, AnnotateFromBase64) for Swagger
- Rename MergePdfsRequest -> MergePdfsBase64Request for clarity
- Add Name attributes to merge endpoints (MergeFromFiles, MergeFromBase64) to fix Swagger conflict
- Base64 FormatException wrapped in BadRequestException
2026-07-21 12:28:43 +02:00
22ac2889af feat(application): Add AddAnnotationCommand with handler and validator
- Create AddAnnotationCommand (Command/Handler/Validator merged in single file)
- Use primary constructors for handler (IPdfProcessor dependency)
- FluentValidation rules: stream required, pageNumber > 0, content for FreeText/StickyNote
- Validate textMarkupStyle required for TextMarkup annotations
- Validate color format (6-digit hex) and rectangle coordinates (X2>X1, Y2>Y1)
2026-07-21 12:28:30 +02:00
a23c78ec3a test(infrastructure): Add 12 unit tests for AddAnnotationAsync
- 5 happy path tests (one per annotation type)
- 7 validation error tests (empty stream, invalid position, page number, content, style, color)
- All tests passing (12/12)
- Total unit tests: 37 (25 previous + 12 annotation)
2026-07-21 12:28:17 +02:00
41f97ce533 feat(infrastructure): Implement DevExpressPdfProcessor.AddAnnotationAsync
- Implement AddAnnotationAsync using DevExpress PdfDocumentProcessor
- Add 5 private helper methods (one per annotation type)
- Add ParseColor helper (hex string to PdfRGBColor)
- Validation: content required for FreeText/StickyNote, style for TextMarkup
- Default colors: Yellow for TextMarkup, Red for others
- Handle DevExpress API quirks (TextMarkupStyle.StrikeOut capitalization)
2026-07-21 12:28:05 +02:00
25fbea205f feat(infrastructure): Add IPdfProcessor.AddAnnotationAsync interface
- Add AddAnnotationAsync method with 8 parameters
- Support Stream-based PDF input (Position=0 required)
- Accept annotation type, page number, rectangle coordinates
- Optional parameters: content, author, color (hex), textMarkupStyle
- Returns annotated PDF as byte array
2026-07-21 12:27:52 +02:00
e95f070b9b feat(domain): Add annotation value objects for Feature 6
- Add AnnotationType enum (TextMarkup, FreeText, StickyNote, Circle, Square)
- Add TextMarkupStyle enum (Highlight, Underline, Strikeout)
- Support 5 annotation types as per CONTROLLER_ENDPOINTS.md requirements
2026-07-21 12:27:41 +02:00
aafe46a738 docs: Update AGENTS.md for Feature 5 (PDF Merge) completion
- Update test count: 82 passed, 7 skipped (was 62 passed)
- Update PdfOperationsController status: Partial (1/N endpoints), 7 tests
- Add PdfOperationsController Status section with merge endpoint details
- Mark merge endpoint as DONE (Phase 1, Priority 5)
- List remaining Phase 2 endpoints: stamp, annotate
2026-07-21 10:22:04 +02:00
0bec759396 test: Add 7 integration tests for PdfOperationsController merge endpoint
Multipart tests (4):
- POST_Merge_Multipart_TwoPdfs_Returns200WithMergedPdf
- POST_Merge_Multipart_ThreePdfs_Returns200
- POST_Merge_Multipart_SinglePdf_Returns400
- POST_Merge_Multipart_CorruptedPdf_Returns400Or500 (flexible assertion)

Base64 tests (3):
- POST_Merge_Base64_TwoPdfs_Returns200WithMergedPdf
- POST_Merge_Base64_ThreePdfs_Returns200
- POST_Merge_Base64_SinglePdf_Returns400

Skipped tests (2):
- POST_Merge_Multipart_WithPageRanges_Returns200 (multipart List<string?> binding complex)
- POST_Merge_Multipart_InvalidPageRange_Returns400 (page ranges work via JSON endpoint)
2026-07-21 10:21:46 +02:00
5c3fafff1b feat: Add PdfOperationsController with dual-input merge endpoints
- Add PdfOperationsController.cs with route '/api/pdf/operations'
- MergeFromFiles: POST /merge (multipart/form-data) - accepts List<IFormFile>
- MergeFromBase64: POST /merge (application/json) - accepts MergePdfsRequest DTO
- Returns merged PDF as FileContentResult (application/pdf)
- Supports optional page ranges via JSON endpoint only (multipart binding complex)
- XML documentation with response codes (200, 400, 500)
- Uses primary constructor pattern
2026-07-21 10:21:27 +02:00
bc273c7f4f feat: Add MergePdfsCommand with merged Command/Handler/Validator
- Add MergePdfsCommand.cs (Vertical Slice pattern)
- Command: IRequest<byte[]> with PdfStreams + PageRanges properties
- Handler: Calls IPdfProcessor.MergePdfsAsync, uses primary constructor
- Validator: Validates minimum 2 PDFs, page ranges count matches PDF count
- All 3 classes in single file (Command/Handler/Validator merged)
2026-07-21 10:21:10 +02:00
e12b64a517 test: Add 10 unit tests for MergePdfsAsync + LoadTestPdfAsStream helper
Unit tests (10):
- MergePdfsAsync_TwoPdfs_ReturnsMergedPdf
- MergePdfsAsync_ThreePdfs_ReturnsMergedPdf
- MergePdfsAsync_WithNullPageRanges_MergesAllPages
- MergePdfsAsync_WithEmptyPageRanges_MergesAllPages
- MergePdfsAsync_WithRangeFormat_MergesSelectedPages
- MergePdfsAsync_SinglePdf_ThrowsBadRequestException
- MergePdfsAsync_InvalidPageRangeCount_ThrowsBadRequestException
- MergePdfsAsync_InvalidPageRangeFormat_ThrowsBadRequestException
- MergePdfsAsync_PageNumberOutOfRange_ThrowsBadRequestException
- MergePdfsAsync_StreamNotAtPositionZero_ThrowsBadRequestException

Helper:
- Add LoadTestPdfAsStream helper for Stream-returning test setup
2026-07-21 10:20:53 +02:00
3598c5f9c6 feat: Implement DevExpressPdfProcessor.MergePdfsAsync with page range support
- Implement MergePdfsAsync: merges multiple PDFs with optional page ranges
- Add ParsePageRange helper: parses '1-3,5' format, validates page numbers
- Stream-based pipeline (no byte[] buffering)
- Validates: Position = 0, minimum 2 PDFs, page ranges count
- Uses DevExpress PdfDocumentProcessor for actual merge operation
- Returns merged PDF as byte array
2026-07-21 10:20:35 +02:00
522de8a863 feat: Add IPdfProcessor.MergePdfsAsync interface
- Add MergePdfsAsync method to IPdfProcessor interface
- Parameters: IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges
- Returns: Task<byte[]> (merged PDF)
- Validates: Minimum 2 PDFs, stream Position = 0, page ranges count matches PDF count
- Supports optional page ranges (e.g., '1-3,5' or null for all pages)
2026-07-21 10:20:18 +02:00
cb552e54e7 docs: Update AGENTS.md - PdfAttachmentController 2/3 endpoints, 62 tests 2026-07-21 09:27:14 +02:00
fa4e55242d fix: Update SwissQrCode test - ArgumentException to BadRequestException 2026-07-21 09:26:51 +02:00
e14044c48a test: Add PdfAttachmentController extract endpoint integration tests (6 tests) 2026-07-21 09:26:43 +02:00
34e38f19e5 feat: Add PdfAttachmentController extract endpoints (multipart + Base64) 2026-07-21 09:26:34 +02:00
61b1595258 test: Add ExtractAttachmentsAsync unit tests (ZIP validation, edge cases) 2026-07-21 09:26:27 +02:00
26458a4017 feat: Implement DevExpressPdfProcessor.ExtractAttachmentsAsync with ZIP packaging 2026-07-21 09:26:18 +02:00
2c673ea98e feat: Add IPdfProcessor.ExtractAttachmentsAsync interface method 2026-07-21 09:26:11 +02:00
1989ca7ef7 feat: Add ExtractPdfAttachments Application layer (Command/Handler/Validator merged) 2026-07-21 09:26:04 +02:00
0f4d860176 test(integration): migrate integration tests to Controller DTOs and update assertions
Test Changes:
- Use Controller DTOs (ValidatePdfBase64Request, ValidatePdfABase64Request, CheckPdfAttachmentsRequest, ExtractSwissQrCodeBase64Request)
- Remove direct Query object usage in HTTP tests (architectural violation)
- Update imports: DocumentOperator.API.Controllers namespace

Assertion Updates:
- Invalid Base64 tests: case-insensitive regex (?i)base.?64 (FormatException message contains 'Base-64' with hyphen)
- Empty PDF tests: regex match for 'Base64|empty|stream' (flexible validation error matching)
- Corrupted PDF test: Expect 500 Internal Server Error (DevExpress exception propagates naturally)

Result: 52 tests pass, 6 skipped (IBAN validation - DevExpress limitation)
2026-07-20 16:33:19 +02:00
645dfceafa test(unit): migrate unit tests to Stream API
Application Handler Tests:
- ValidatePdfHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- ValidatePdfAQueryHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- CheckPdfAttachmentsQueryHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- Remove Base64/PdfBytes property usage

Infrastructure Tests:
- DevExpressSwissQrCodeProcessorTests: LoadTestPdf() returns Stream
- All test methods use 'using var stream' pattern
- Add test: ExtractSwissQrCodeAsync_StreamNotAtBeginning_ThrowsBadRequestException

Result: All unit tests pass with Stream-based API
2026-07-20 16:33:00 +02:00
07be9b9f02 refactor(domain): remove obsolete exception types
Deleted:
- PdfProcessingException: Obsolete, DevExpress exceptions now propagate naturally
- SwissQrCodeNotFoundException: Moved to Application layer (feature-specific exception)

Rationale:
- PdfProcessingException was wrapping library exceptions unnecessarily
- Better to let infrastructure exceptions propagate → middleware handles as 500
- SwissQrCodeNotFoundException is application-level concern, not domain
2026-07-20 16:32:36 +02:00
a1e8575018 refactor(api): remove generic exception handling from middleware
Remove FormatException/ArgumentException handling:
- These are framework exceptions, not application-specific
- May come from internal libraries (false positives for 400 Bad Request)
- Controllers now wrap Base64 conversion with BadRequestException explicitly

Remove PdfProcessingException handling:
- Exception type removed (obsolete)
- DevExpress exceptions now propagate naturally → 500 Internal Server Error

Current exception mapping:
- ValidationException (FluentValidation) → 400 Bad Request
- BadRequestException (custom) → 400 Bad Request
- NotFoundException (custom) → 404 Not Found
- SwissQrCodeNotFoundException (custom) → 404 Not Found
- All others → 500 Internal Server Error
2026-07-20 16:32:19 +02:00
b4befde418 refactor(api): migrate controllers to Stream API with Base64 validation
PdfValidationController:
- ValidateFromFile: IFormFile.OpenReadStream() direct usage (no byte[] copy)
- ValidateFromBase64: try-catch Convert.FromBase64String → BadRequestException
- ValidatePdfAFromFile: IFormFile.OpenReadStream() direct usage
- ValidatePdfAFromBase64: try-catch Convert.FromBase64String → BadRequestException

PdfAttachmentController:
- CheckAttachmentsFromBase64: try-catch Convert.FromBase64String → BadRequestException

SwissQrCodeController:
- ExtractFromBase64: try-catch Convert.FromBase64String → BadRequestException

All controllers: Add using DocumentOperator.Domain.Common.Exceptions for BadRequestException
2026-07-20 16:32:03 +02:00
1af158840e refactor(infrastructure): implement Stream-based PDF processing
DevExpressPdfProcessor:
- ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync: Stream parameters
- Defensive Position=0 validation (BadRequestException for seekable streams not at beginning)
- Remove unsafe Position reset (non-seekable stream compatibility)
- Remove PdfProcessingException wrapping (let DevExpress exceptions propagate naturally)

DevExpressSwissQrCodeProcessor:
- ExtractSwissQrCodeAsync: Stream parameter
- Defensive Position=0 validation
- Remove unsafe Position reset

Memory optimization: MemoryStream.TryGetBuffer fast path for byte[] extraction
2026-07-20 16:31:47 +02:00
5dc2e38507 refactor(application): update processor interfaces for Stream API
- IPdfProcessor: ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync now accept Stream
- ISwissQrCodeProcessor: ExtractSwissQrCodeAsync now accepts Stream
- Update XML documentation: Position=0 requirement, non-seekable stream support
- Exception documentation: BadRequestException for validation errors (stream empty/invalid/wrong position)
2026-07-20 16:31:31 +02:00
c93488c29f refactor(application): migrate all queries to Stream-based API
- Replace byte[] and Base64String with required Stream PdfStream
- Simplify validators: remove XOR/Base64 validation, only check NotNull
- Affected queries: ValidatePdfQuery, ValidatePdfAQuery, CheckPdfAttachmentsQuery, ExtractSwissQrCodeQuery
- Memory efficiency: direct stream usage, no intermediate byte[] copies
2026-07-20 16:31:14 +02:00
46 changed files with 4091 additions and 428 deletions

View File

@@ -188,7 +188,7 @@ After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQ
dotnet build
```
**Run tests (30 tests as of Feature 3 - PDF/A Validation):**
**Run tests (101 passed, 7 skipped as of Feature 7 - PDF Stamp):**
```powershell
dotnet test
```
@@ -197,7 +197,8 @@ dotnet test
```powershell
dotnet run --project DocumentOperator.API
```
Swagger UI: `https://localhost:<port>/swagger`
Swagger UI: `https://localhost:7186/swagger`
Serilog UI: `https://localhost:7186/serilog-ui` (Web-based log viewer)
**Target framework:** .NET 8.0
**SDK required:** 8.0.412 or later (repo has 8.0.412–10.0.203 available)
@@ -214,6 +215,8 @@ Swagger UI: `https://localhost:<port>/swagger`
| **MediatR** (14.1.0) | CQRS: 1 handler per feature | Application layer |
| **FluentValidation** (12.1.1) | Request validation (runs via ValidationBehavior before handlers) | Application layer |
| **Serilog.AspNetCore** (10.0.0) | Structured logging | API layer |
| **Serilog.Sinks.SQLite** (7.0.0) | SQLite log persistence | API layer |
| **Serilog.UI** (3.2.0) + **Serilog.UI.SqliteProvider** (1.1.0) | Web-based log viewer UI | API layer |
**Critical:** DevExpress requires a license. All PDF operations use `DevExpress.Pdf.PdfDocumentProcessor`.
@@ -275,10 +278,20 @@ Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The midd
|-----------|--------|-------|
| **PdfValidationController** | ✅ DONE | 13 (7 validate + 6 validate-pdfa) |
| **SwissQrCodeController** | ✅ DONE | 2 |
| **PdfAttachmentController** | ⏳ Pending | 0 |
| **PdfOperationsController** | ⏳ Pending | 0 |
| **PdfAttachmentController** | ⏳ Partial (2/3 endpoints) | 10 (4 check + 6 extract) |
| **PdfOperationsController** | ⏳ Partial (3/3 endpoints, integration tests pending) | 29 (7 merge + 22 annotate: 12 unit + 10 integration) |
| **PdfConversionController** | ⏳ Pending | 0 |
**PdfAttachmentController Status:**
- ✅ `POST /api/pdf/attachments/check` - DONE (with multipart + Base64 support)
- ✅ `POST /api/pdf/attachments/extract` - DONE (Phase 1, Priority 4) - Returns ZIP with all attachments
- ⏳ `POST /api/pdf/attachments/add` - TODO (Phase 2, Priority 7)
**PdfOperationsController Status:**
- ✅ `POST /api/pdf/operations/merge` - DONE (Phase 1, Priority 5) - Merges multiple PDFs with optional page ranges (multipart + Base64)
- ✅ `POST /api/pdf/operations/annotate` - DONE (Phase 2, Priority 6) - Adds annotations (TextMarkup/FreeText/StickyNote/Circle/Square) with multipart + Base64 support
- ✅ `POST /api/pdf/operations/stamp` - DONE (Phase 2, Priority 6) - Adds text/image/predefined stamps (multipart + Base64 support, origin/rotation/opacity/placement)
**Note:** PdfRenderController removed - moved to .NET client library.
---
@@ -375,10 +388,19 @@ DocumentOperator.Tests/
- Matches Application layer structure exactly
4. **Test Pyramid:**
- **Unit tests (15):** Fast, isolated, many scenarios
- **Integration tests (15):** Slower, full pipeline, critical paths only
- **Unit tests (60+):** Fast, isolated, many scenarios
- **Integration tests (27):** Slower, full pipeline, critical paths only
**Test count:** 30 tests total (as of Feature 3 - PDF/A Validation)
**Test count:** 101 passed, 7 skipped (as of Feature 6 - PDF Annotation)
**Test breakdown by feature:**
- Feature 1 (PDF Validation): 13 integration tests
- Feature 2 (Swiss QR Code): 2 integration tests
- Feature 3 (PDF/A Validation): 6 integration tests (validate-pdfa) + 4 unit tests (handler)
- Feature 4 (PDF Attachments): 10 tests (4 check + 6 extract integration)
- Feature 5 (PDF Merge): 7 integration + 10 unit tests (DevExpressPdfProcessor)
- Feature 6 (PDF Annotation): 10 integration + 12 unit tests (DevExpressPdfProcessor)
- Infrastructure: 37 unit tests (DevExpressPdfProcessor for validation, attachments, merge, annotation)
**FluentValidation in tests:**
- Base64 format validation happens in `ValidatePdfQueryValidator` and `ValidatePdfAQueryValidator`

View File

@@ -1,4 +1,5 @@
using Microsoft.OpenApi.Models;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using System.Reflection;
namespace DocumentOperator.API.Configuration
@@ -12,16 +13,22 @@ namespace DocumentOperator.API.Configuration
/// Adds Swagger documentation generation to the service collection.
/// </summary>
/// <param name="services">The service collection to add Swagger to.</param>
/// <param name="configuration">Configuration to read SwaggerSettings from.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
public static IServiceCollection AddSwaggerDocumentation(
this IServiceCollection services,
IConfiguration configuration)
{
var swaggerSettings = configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
?? new SwaggerSettings();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
options.SwaggerDoc(swaggerSettings.Version, new OpenApiInfo
{
Title = "DD Document Operator API",
Version = "v1",
Description = "PDF Verarbeitungs-Service für Validierung, Stempel, Zertifikate, Anhänge & Zusammenführung"
Title = swaggerSettings.Title,
Version = swaggerSettings.Version,
Description = swaggerSettings.Description
});
// Resolve conflicting actions: Keep first variant

View File

@@ -0,0 +1,33 @@
namespace DocumentOperator.API.Configuration;
/// <summary>
/// Configuration settings for Swagger/OpenAPI documentation.
/// </summary>
public class SwaggerSettings
{
/// <summary>
///
/// </summary>
public const string SectionName = "SwaggerSettings";
/// <summary>
/// Enable Swagger UI in Production environment.
/// Default: true (allows production testing/debugging).
/// </summary>
public bool EnableInProduction { get; set; } = true;
/// <summary>
/// API title displayed in Swagger UI.
/// </summary>
public string Title { get; set; } = "DocumentOperator API";
/// <summary>
/// API version.
/// </summary>
public string Version { get; set; } = "v1";
/// <summary>
/// API description displayed in Swagger UI.
/// </summary>
public string Description { get; set; } = "PDF document processing service";
}

View File

@@ -1,5 +1,7 @@
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ExtractPdfAttachments;
using DocumentOperator.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -33,13 +35,11 @@ public class PdfAttachmentController(IMediator mediator) : ControllerBase
IFormFile file,
CancellationToken cancellationToken)
{
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send query to MediatR (ValidationBehavior runs automatically)
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
var query = new CheckPdfAttachmentsQuery { PdfStream = pdfStream };
var result = await mediator.Send(query, cancellationToken);
return Ok(result);
@@ -64,12 +64,99 @@ public class PdfAttachmentController(IMediator mediator) : ControllerBase
[FromBody] CheckPdfAttachmentsRequest request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send query to MediatR (ValidationBehavior runs automatically)
var query = new CheckPdfAttachmentsQuery { Base64Pdf = request.Base64Pdf };
var query = new CheckPdfAttachmentsQuery { PdfStream = pdfStream };
var result = await mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="file">The PDF file to extract attachments from</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZIP archive containing all extracted attachments</returns>
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
/// <response code="404">PDF contains no attachments</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("extract")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractAttachmentsFromFile(
IFormFile file,
CancellationToken cancellationToken)
{
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send command to MediatR
var command = new ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
byte[] zipBytes = await mediator.Send(command, cancellationToken);
// Return ZIP file
return File(zipBytes, "application/zip", "attachments.zip");
}
/// <summary>
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZIP archive containing all extracted attachments</returns>
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
/// <response code="404">PDF contains no attachments</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("extract")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractAttachmentsFromBase64(
[FromBody] ExtractPdfAttachmentsRequest request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send command to MediatR
var command = new ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
byte[] zipBytes = await mediator.Send(command, cancellationToken);
// Return ZIP file
return File(zipBytes, "application/zip", "attachments.zip");
}
}
/// <summary>
@@ -83,3 +170,15 @@ public record CheckPdfAttachmentsRequest
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public string Base64Pdf { get; init; } = string.Empty;
}
/// <summary>
/// Request DTO for Base64-encoded PDF attachment extraction
/// </summary>
public record ExtractPdfAttachmentsRequest
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public required string Base64Pdf { get; init; }
}

View File

@@ -0,0 +1,728 @@
using DocumentOperator.Application.AddAnnotation;
using DocumentOperator.Application.AddStamp;
using DocumentOperator.Application.MergePdfs;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.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] AddAnnotationBase64Command 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 Base64-encoded PDF merge operation (API layer only - converts to MergePdfsCommand)
/// </summary>
public record MergePdfsBase64Request
{
/// <summary>
/// Array of Base64-encoded PDF files (minimum 2 required)
/// </summary>
/// <example>["JVBERi0xLjQK...", "JVBERi0xLjQK..."]</example>
public required List<string> Base64Pdfs { get; init; }
/// <summary>
/// Optional page ranges per PDF (null = all pages).
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If provided, array length must match Base64Pdfs length.
/// </summary>
/// <example>["1-2", "1,3,5", null]</example>
public List<string?>? PageRanges { get; init; }
}
/// <summary>
/// Request DTO for Base64-encoded PDF annotation (API layer only - converts to AddAnnotationCommand)
/// </summary>
public record AddAnnotationBase64Command
{
/// <summary>
/// Base64-encoded PDF file
/// </summary>
/// <example>"JVBERi0xLjQK..."</example>
public required string Base64Pdf { get; init; }
/// <summary>
/// Type of annotation to add
/// </summary>
/// <example>TextMarkup</example>
public required AnnotationType AnnotationType { get; init; }
/// <summary>
/// Target page number (1-indexed)
/// </summary>
/// <example>1</example>
public required int PageNumber { get; init; }
/// <summary>
/// Rectangle X1 coordinate (left)
/// </summary>
/// <example>100.0</example>
public required double X1 { get; init; }
/// <summary>
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
/// </summary>
/// <example>100.0</example>
public required double Y1 { get; init; }
/// <summary>
/// Rectangle X2 coordinate (right). Optional if Width is provided.
/// </summary>
/// <example>200.0</example>
public double? X2 { get; init; }
/// <summary>
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
/// </summary>
/// <example>120.0</example>
public double? Y2 { get; init; }
/// <summary>
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
/// </summary>
/// <example>100.0</example>
public double? Width { get; init; }
/// <summary>
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
/// </summary>
/// <example>20.0</example>
public double? Height { get; init; }
/// <summary>
/// Annotation content (required for FreeText and StickyNote)
/// </summary>
/// <example>"Important text to highlight"</example>
public string? Content { get; init; }
/// <summary>
/// Author name (optional)
/// </summary>
/// <example>"John Doe"</example>
public string? Author { get; init; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red). Optional - defaults vary by annotation type.
/// </summary>
/// <example>"FFFF00"</example>
public string? Color { get; init; }
/// <summary>
/// Text markup style (Highlight, Underline, or Strikeout). Required for TextMarkup annotations.
/// </summary>
/// <example>Highlight</example>
public TextMarkupStyle? TextMarkupStyle { get; init; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
/// <example>BottomLeft</example>
public AnnotationOrigin Origin { get; init; } = 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; }
}
/// <summary>
/// Request DTO for Base64-encoded PDF stamp operation (API layer only - converts to AddStampCommand)
/// </summary>
public record AddStampBase64Request
{
/// <summary>
/// Base64-encoded PDF file
/// </summary>
/// <example>"JVBERi0xLjQK..."</example>
public required string Base64Pdf { get; init; }
/// <summary>
/// Type of stamp (Text, Image, or Predefined)
/// </summary>
/// <example>Text</example>
public required StampType StampType { get; init; }
/// <summary>
/// Target page numbers (1-indexed). Null or empty = all pages.
/// </summary>
/// <example>[1, 3, 5]</example>
public int[]? PageNumbers { get; init; }
/// <summary>
/// Stamp position X coordinate
/// </summary>
/// <example>100.0</example>
public required double X { get; init; }
/// <summary>
/// Stamp position Y coordinate
/// </summary>
/// <example>100.0</example>
public required double Y { get; init; }
/// <summary>
/// Stamp width (optional, auto-size for images if not specified)
/// </summary>
/// <example>200.0</example>
public double? Width { get; init; }
/// <summary>
/// Stamp height (optional, auto-size for images if not specified)
/// </summary>
/// <example>50.0</example>
public double? Height { get; init; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
/// <example>BottomLeft</example>
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
/// <summary>
/// Text content (required for Text stamps)
/// </summary>
/// <example>"CONFIDENTIAL"</example>
public string? Text { get; init; }
/// <summary>
/// Font name (default: Arial)
/// </summary>
/// <example>"Arial"</example>
public string? FontName { get; init; }
/// <summary>
/// Font size in points (default: 12)
/// </summary>
/// <example>24.0</example>
public double? FontSize { get; init; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
/// </summary>
/// <example>"FF0000"</example>
public string? Color { get; init; }
/// <summary>
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
/// </summary>
/// <example>0.5</example>
public double? Opacity { get; init; }
/// <summary>
/// Rotation angle in degrees (0-360, default: 0)
/// </summary>
/// <example>45.0</example>
public double? Rotation { get; init; }
/// <summary>
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
/// </summary>
/// <example>Foreground</example>
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
/// <summary>
/// Base64-encoded image (required for Image stamps, PNG/JPEG)
/// </summary>
/// <example>"iVBORw0KGgoAAAANSUhEUgAA..."</example>
public string? Base64Image { get; init; }
/// <summary>
/// Predefined stamp type (required for Predefined stamps)
/// </summary>
/// <example>Confidential</example>
public PredefinedStampType? PredefinedType { get; init; }
}

View File

@@ -1,6 +1,7 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -42,13 +43,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -57,7 +56,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
/// <summary>
/// Validates a PDF document and returns metadata (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</param>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF metadata (page count, file size, PDF version, attachments)</returns>
/// <response code="200">PDF is valid, metadata returned</response>
@@ -69,10 +68,24 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidateFromBase64(
[FromBody] ValidatePdfQuery query,
[FromBody] ValidatePdfBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ValidatePdfQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -106,13 +119,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -121,7 +132,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
/// <summary>
/// Validates a PDF/A document and checks conformance level (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</param>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A metadata (conformance level, errors, warnings)</returns>
/// <response code="200">PDF/A validation completed, results returned</response>
@@ -133,12 +144,50 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidatePdfAFromBase64(
[FromBody] ValidatePdfAQuery query,
[FromBody] ValidatePdfABase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
}
/// <summary>
/// Request DTO for Base64-encoded PDF validation
/// </summary>
public record ValidatePdfBase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public string Base64Pdf { get; init; } = string.Empty;
}
/// <summary>
/// Request DTO for Base64-encoded PDF/A validation
/// </summary>
public record ValidatePdfABase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public string Base64Pdf { get; init; } = string.Empty;
}

View File

@@ -1,5 +1,6 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -17,7 +18,7 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
/// Extracts Swiss QR Code from the last page of a PDF document (multipart/form-data)
/// </summary>
/// <param name="file">PDF file containing Swiss QR Code</param>
/// <param name="raw"></param>
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
/// <response code="200">Swiss QR Code extracted successfully</response>
@@ -43,15 +44,13 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
Status = StatusCodes.Status400BadRequest
});
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ExtractSwissQrCodeQuery
{
PdfBytes = pdfBytes
PdfStream = pdfStream
};
var result = await Mediator.Send(query, cancellationToken);
@@ -61,8 +60,8 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
/// <summary>
/// Extracts Swiss QR Code from the last page of a PDF document (Base64 JSON)
/// </summary>
/// <param name="query">References array + PDF as Base64 string</param>
/// <param name="raw"></param>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
/// <response code="200">Swiss QR Code extracted successfully</response>
@@ -76,13 +75,39 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractFromBase64(
[FromBody] ExtractSwissQrCodeQuery query,
[FromBody] ExtractSwissQrCodeBase64Request request,
[FromQuery] bool raw = false,
CancellationToken cancellationToken = default)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ExtractSwissQrCodeQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(raw ? result.RawLines : result.Bill);
}
}
/// <summary>
/// Request DTO for Base64-encoded Swiss QR Code extraction
/// </summary>
public record ExtractSwissQrCodeBase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public required string Base64Pdf { get; init; }
}

View File

@@ -4,15 +4,30 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>DocumentOperator.API</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>DocumentOperator.API</Product>
<Version>1.0.0</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<InformationalVersion>1.0.0</InformationalVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<Description>PDF Document Operations REST API - Validation, Swiss QR Code extraction, attachments, merge, annotation, stamp operations powered by DevExpress Office File API</Description>
<PackageTags>pdf document operator validation swiss-qr-code annotations stamp devexpress</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.58" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>

View File

@@ -1,5 +1,4 @@
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Exceptions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using System.Net;
@@ -67,7 +66,7 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
}
),
// Not Found Exception (404 Not Found)
// Bad Request Exception (400 Bad Request)
BadRequestException badReqEx => (
HttpStatusCode.BadRequest,
new ProblemDetails
@@ -93,19 +92,6 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
}
),
// Swiss QR Code Not Found Exception (404 Not Found)
SwissQrCodeNotFoundException qrNotFoundEx => (
HttpStatusCode.NotFound,
new ProblemDetails
{
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
Title = "Swiss QR Code Not Found",
Status = (int)HttpStatusCode.NotFound,
Detail = qrNotFoundEx.Message,
Instance = context.Request.Path
}
),
// Generic Exception (500 Internal Server Error)
_ => (
HttpStatusCode.InternalServerError,

View File

@@ -1,4 +1,8 @@
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
using Scalar.AspNetCore;
using DocumentOperator.Infrastructure.Configuration;
using DocumentOperator.Application;
using DocumentOperator.Infrastructure;
@@ -34,6 +38,9 @@ try
builder.Services.Configure<ApiKeySettings>(
builder.Configuration.GetSection(ApiKeySettings.SectionName));
builder.Services.Configure<SwaggerSettings>(
builder.Configuration.GetSection(SwaggerSettings.SectionName));
// ========================================
// 3. Services (Clean Architecture Layers)
// ========================================
@@ -42,10 +49,26 @@ try
builder.Services.AddControllers(); // Controllers (Controller-based API)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerDocumentation();
builder.Services.AddSwaggerDocumentation(builder.Configuration);
// ========================================
// 4. Build App
// 4. Serilog.UI Configuration
// ========================================
var logDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
builder.Services.AddSerilogUi(logUIOpt =>
{
logUIOpt.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
dbOpt.WithTable("Logs");
});
});
// ========================================
// 5. Build App
// ========================================
var app = builder.Build();
@@ -56,10 +79,32 @@ try
// Exception Handling FIRST (catches all exceptions from subsequent middleware)
app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment())
// ========================================
// Swagger/OpenAPI (Conditional based on settings)
// ========================================
var swaggerSettings = builder.Configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
?? new SwaggerSettings();
if (app.Environment.IsDevelopment() || swaggerSettings.EnableInProduction)
{
app.UseSwagger();
app.UseSwaggerUI();
// Swagger UI (classic)
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint($"/swagger/{swaggerSettings.Version}/swagger.json",
$"{swaggerSettings.Title} {swaggerSettings.Version}");
options.RoutePrefix = "swagger"; // /swagger
});
// Scalar UI (modern alternative)
app.MapScalarApiReference(options =>
{
options
.WithTitle(swaggerSettings.Title)
.WithTheme(ScalarTheme.DeepSpace)
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});
}
app.UseSerilogRequestLogging(); // Log HTTP Requests
@@ -67,7 +112,12 @@ try
app.UseHttpsRedirection();
// ========================================
// 6. Endpoints (Controller-based API)
// 6. Serilog.UI Dashboard
// ========================================
app.UseSerilogUi(); // Accessible at /serilog-ui
// ========================================
// 7. Endpoints (Controller-based API)
// ========================================
app.MapControllers(); // Maps all [ApiController] controllers

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\DocumentOperator\PreRelease\API\net8\$(Version)\DocumentOperator.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>DocumentOperator.API</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -7,6 +7,17 @@
},
"AllowedHosts": "*",
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentOperator.API"
},
"SwaggerSettings": {
"EnableInProduction": true,
"Title": "DocumentOperator API",
"Version": "v1",
"Description": "PDF document processing service using DevExpress"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
@@ -30,6 +41,14 @@
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "SQLite",
"Args": {
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentOperator.API\\logs.db",
"tableName": "Logs",
"storeTimestampInUtc": true
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
@@ -62,5 +81,6 @@
"IsActive": true
}
}
}
},
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
}

View File

@@ -0,0 +1,78 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Models.ValueObjects;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddAnnotation;
/// <summary>
/// Command to add an annotation to a PDF document.
/// </summary>
public record AddAnnotationCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required AnnotationType AnnotationType { get; init; }
public required int PageNumber { get; init; }
public required (double X1, double Y1, double X2, double Y2) Rectangle { get; init; }
public string? Content { get; init; }
public string? Author { get; init; }
public string? Color { get; init; }
public TextMarkupStyle? TextMarkupStyle { get; init; }
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
}
/// <summary>
/// Handler for AddAnnotationCommand.
/// </summary>
public class AddAnnotationHandler(IPdfProcessor pdfProcessor) : IRequestHandler<AddAnnotationCommand, byte[]>
{
public async Task<byte[]> Handle(AddAnnotationCommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.AddAnnotationAsync(
request.PdfStream,
request.AnnotationType,
request.PageNumber,
request.Rectangle,
request.Content,
request.Author,
request.Color,
request.TextMarkupStyle,
request.Origin);
}
}
/// <summary>
/// Validator for AddAnnotationCommand.
/// </summary>
public class AddAnnotationValidator : AbstractValidator<AddAnnotationCommand>
{
public AddAnnotationValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.PageNumber)
.GreaterThan(0)
.WithMessage("Page number must be greater than 0");
RuleFor(x => x.Content)
.NotEmpty()
.When(x => x.AnnotationType == AnnotationType.FreeText || x.AnnotationType == AnnotationType.StickyNote)
.WithMessage("Content is required for FreeText and StickyNote annotations");
RuleFor(x => x.TextMarkupStyle)
.NotNull()
.When(x => x.AnnotationType == AnnotationType.TextMarkup)
.WithMessage("TextMarkupStyle is required for TextMarkup annotations");
RuleFor(x => x.Color)
.Matches("^[0-9A-Fa-f]{6}$")
.When(x => !string.IsNullOrWhiteSpace(x.Color))
.WithMessage("Color must be a 6-digit hex value (e.g., 'FF0000' for red)");
RuleFor(x => x.Rectangle)
.Must(r => r.X2 > r.X1 && r.Y2 > r.Y1)
.WithMessage("Rectangle coordinates must define a valid area (X2 > X1 and Y2 > Y1)");
}
}

View File

@@ -0,0 +1,125 @@
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddStamp;
/// <summary>
/// Command to add a stamp (text, image, or predefined) to PDF pages.
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
/// </summary>
public record AddStampCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
public int[]? PageNumbers { get; init; } // null = all pages
public required (double X, double Y) Position { get; init; }
public (double Width, double Height)? Size { get; init; }
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
public string? Text { get; init; }
public string? FontName { get; init; }
public double? FontSize { get; init; }
public string? Color { get; init; }
public double? Opacity { get; init; }
public double? Rotation { get; init; }
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
public byte[]? ImageBytes { get; init; }
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
}
/// <summary>
/// Handler for AddStampCommand.
/// </summary>
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
{
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
{
return await pdfProcessor.AddStampAsync(
command.PdfStream,
command.StampType,
command.PageNumbers,
command.Position,
command.Size,
command.Origin,
command.Text,
command.FontName,
command.FontSize,
command.Color,
command.Opacity,
command.Rotation,
command.Placement,
command.ImageBytes,
command.PredefinedType
);
}
}
/// <summary>
/// Validator for AddStampCommand.
/// </summary>
public class AddStampValidator : AbstractValidator<AddStampCommand>
{
public AddStampValidator()
{
RuleFor(x => x.PdfStream)
.NotNull().WithMessage("PDF stream is required");
RuleFor(x => x.Position.X)
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
RuleFor(x => x.Position.Y)
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
// Text stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
{
RuleFor(x => x.Text)
.NotEmpty().WithMessage("Text is required for Text stamp type");
});
// Image stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
{
RuleFor(x => x.ImageBytes)
.NotNull().WithMessage("Image bytes are required for Image stamp type")
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
});
// Predefined stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
{
RuleFor(x => x.PredefinedType)
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
});
// Optional parameter validations
When(x => x.Opacity.HasValue, () =>
{
RuleFor(x => x.Opacity!.Value)
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
});
When(x => x.Rotation.HasValue, () =>
{
RuleFor(x => x.Rotation!.Value)
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
});
When(x => x.FontSize.HasValue, () =>
{
RuleFor(x => x.FontSize!.Value)
.GreaterThan(0).WithMessage("Font size must be positive");
});
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
{
RuleFor(x => x.Color!)
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
});
When(x => x.PageNumbers != null, () =>
{
RuleFor(x => x.PageNumbers!)
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
});
}
}

View File

@@ -6,19 +6,14 @@ using MediatR;
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Query for checking PDF attachments (supports both byte array and Base64 input)
/// Query for checking PDF attachments (Stream-based)
/// </summary>
public record CheckPdfAttachmentsQuery : IRequest<AttachmentCheckResult>
{
/// <summary>
/// PDF as byte array (direct upload via multipart/form-data)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (for API clients using application/json)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class CheckPdfAttachmentsQueryHandler(IPdfProcessor PdfProcessor, IMapper
/// </summary>
public async Task<AttachmentCheckResult> Handle(CheckPdfAttachmentsQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Convert to stream for IPdfProcessor
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<AttachmentCheckResult>(attachmentInfo);

View File

@@ -4,41 +4,15 @@ namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Validator for CheckPdfAttachmentsQuery
/// Ensures exactly one input type (PdfBytes OR Base64Pdf) is provided
/// Ensures PdfStream is not null
/// </summary>
public class CheckPdfAttachmentsQueryValidator : AbstractValidator<CheckPdfAttachmentsQuery>
{
public CheckPdfAttachmentsQueryValidator()
{
// Rule 1: Exactly ONE input must be provided (XOR logic)
RuleFor(x => x)
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
(!string.IsNullOrWhiteSpace(x.Base64Pdf)))
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Rule 2: Base64 format validation (if provided)
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf))
.WithMessage("Base64Pdf must be a valid Base64 string");
}
/// <summary>
/// Validates if a string is a valid Base64 format
/// </summary>
private bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return true; // Skip validation if null/empty (handled by Rule 1)
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided and non-empty
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -7,30 +7,142 @@ public interface IPdfProcessor
/// <summary>
/// Validates a PDF and extracts metadata.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <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>
/// <returns>PDF metadata (page count, size, version, attachments)</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<PdfMetadata> ValidateAsync(Stream pdfStream);
/// <summary>
/// Validates a PDF/A document and checks conformance level.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <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>
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<PdfAMetadata> ValidatePdfAAsync(Stream pdfStream);
/// <summary>
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <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>
/// <returns>Attachment information (count, file names, MIME types, sizes)</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<AttachmentInfo> CheckAttachmentsAsync(Stream pdfStream);
/// <summary>
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
/// </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>
/// <returns>ZIP archive containing all extracted attachments as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
/// Thrown when PDF contains no attachments
/// </exception>
Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream);
/// <summary>
/// Merges multiple PDF documents into a single PDF.
/// </summary>
/// <param name="pdfStreams">
/// PDF streams to merge (minimum 2 required). Each stream must be readable and positioned
/// at the beginning (Position = 0). Caller is responsible for disposal.
/// </param>
/// <param name="pageRanges">
/// Optional page ranges per PDF (null = all pages). Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If null or empty for a PDF, all pages are included. Array length must match pdfStreams length if provided.
/// </param>
/// <returns>Merged PDF as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when fewer than 2 PDFs provided, any stream is empty/invalid/not at Position=0,
/// or page range format is invalid
/// </exception>
Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null);
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </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="annotationType">Type of annotation to add (TextMarkup, FreeText, StickyNote, Circle, Square)</param>
/// <param name="pageNumber">Page number (1-based) where annotation should be added</param>
/// <param name="rectangle">Annotation bounding rectangle (X1, Y1, X2, Y2)</param>
/// <param name="content">Annotation content/comment text (required for FreeText and StickyNote)</param>
/// <param name="author">Optional author name</param>
/// <param name="color">Optional annotation color in RGB format (hex string like "FF0000" for red)</param>
/// <param name="textMarkupStyle">Text markup style (Highlight, Underline, Strikeout) - only for TextMarkup type</param>
/// <param name="origin">Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft</param>
/// <returns>Annotated PDF as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid/not at Position=0, page number out of range,
/// rectangle invalid, or content missing for types that require it
/// </exception>
Task<byte[]> AddAnnotationAsync(
Stream pdfStream,
Domain.Models.ValueObjects.AnnotationType annotationType,
int pageNumber,
(double X1, double Y1, double X2, double Y2) rectangle,
string? content = null,
string? author = null,
string? color = null,
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft);
/// <summary>
/// Adds a stamp (text, image, or predefined) to specified pages of a PDF document.
/// </summary>
/// <param name="pdfStream">Input PDF stream (must support reading and seeking)</param>
/// <param name="stampType">Type of stamp (Text, Image, or Predefined)</param>
/// <param name="pageNumbers">Target page numbers (1-based). Null = all pages.</param>
/// <param name="position">Stamp position (X, Y coordinates)</param>
/// <param name="size">Stamp size (Width, Height). Null = auto-size for images.</param>
/// <param name="origin">Coordinate origin (BottomLeft or TopLeft)</param>
/// <param name="text">Text content (required for Text stamps)</param>
/// <param name="fontName">Font name (default: Arial)</param>
/// <param name="fontSize">Font size in points (default: 12)</param>
/// <param name="color">Hex color without # (e.g., "FF0000" for red, default: "000000")</param>
/// <param name="opacity">Opacity 0.0 (transparent) to 1.0 (opaque, default: 0.5)</param>
/// <param name="rotation">Rotation angle in degrees 0-360 (default: 0)</param>
/// <param name="placement">Foreground (on top) or Background (behind content)</param>
/// <param name="imageBytes">Image data (required for Image stamps, PNG/JPEG)</param>
/// <param name="predefinedType">Predefined stamp type (required for Predefined stamps)</param>
/// <returns>Stamped PDF as byte array</returns>
/// <exception cref="BadRequestException">Invalid parameters (missing text/image, invalid page numbers, invalid opacity/rotation)</exception>
/// <exception cref="PdfProcessingException">DevExpress processing error</exception>
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);
}

View File

@@ -12,18 +12,21 @@ public interface ISwissQrCodeProcessor
/// Extracts and parses Swiss QR Code from a PDF document.
/// Returns both parsed Bill object and raw QR text lines.
/// </summary>
/// <param name="pdfBytes">PDF document as byte array</param>
/// <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="pageNumbers">Optional: Specific page numbers to scan (1-indexed). If null, scans all pages starting with last page.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tuple: (Parsed Codecrete Bill, Raw QR lines as string array)</returns>
/// <exception cref="Domain.Exceptions.SwissQrCodeNotFoundException">
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
/// Thrown when no Swiss QR Code is found in the specified pages
/// </exception>
/// <exception cref="Domain.Exceptions.PdfProcessingException">
/// Thrown when PDF processing fails
/// <exception cref="ArgumentException">
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
/// </exception>
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
byte[] pdfBytes,
Stream pdfStream,
int[]? pageNumbers = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,43 @@
using DocumentOperator.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.ExtractPdfAttachments;
/// <summary>
/// Command to extract all embedded files from a PDF document and return as ZIP archive.
/// </summary>
public record ExtractPdfAttachmentsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for ExtractPdfAttachmentsCommand.
/// Extracts all embedded files from PDF and returns as ZIP archive.
/// </summary>
public class ExtractPdfAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ExtractPdfAttachmentsCommand, byte[]>
{
public async Task<byte[]> Handle(ExtractPdfAttachmentsCommand request, CancellationToken cancellationToken)
{
// Delegate to infrastructure layer
return await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
}
}
/// <summary>
/// Validator for ExtractPdfAttachmentsCommand.
/// </summary>
public class ExtractPdfAttachmentsCommandValidator : AbstractValidator<ExtractPdfAttachmentsCommand>
{
public ExtractPdfAttachmentsCommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}

View File

@@ -0,0 +1,53 @@
using DocumentOperator.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.MergePdfs;
/// <summary>
/// Command to merge multiple PDF documents into a single PDF.
/// </summary>
public record MergePdfsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF streams to merge. Minimum 2 required. Each must be at Position=0.
/// </summary>
public required IReadOnlyList<Stream> PdfStreams { get; init; }
/// <summary>
/// Optional page ranges per PDF (null = all pages).
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If provided, array length must match PdfStreams length.
/// </summary>
public IReadOnlyList<string?>? PageRanges { get; init; }
}
/// <summary>
/// Handler for MergePdfsCommand.
/// Delegates PDF merge operation to infrastructure layer.
/// </summary>
public class MergePdfsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<MergePdfsCommand, byte[]>
{
public async Task<byte[]> Handle(MergePdfsCommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.MergePdfsAsync(request.PdfStreams, request.PageRanges);
}
}
/// <summary>
/// Validator for MergePdfsCommand.
/// </summary>
public class MergePdfsCommandValidator : AbstractValidator<MergePdfsCommand>
{
public MergePdfsCommandValidator()
{
RuleFor(x => x.PdfStreams)
.NotNull()
.WithMessage("PDF streams are required");
RuleFor(x => x.PdfStreams)
.Must(streams => streams != null && streams.Count >= 2)
.WithMessage("At least 2 PDF files are required for merging");
}
}

View File

@@ -6,19 +6,14 @@ using MediatR;
namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
/// Query for extracting Swiss QR Code from PDF (Stream-based)
/// </summary>
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -34,11 +29,8 @@ public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcesso
/// </summary>
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Extract: returns (Bill, RawLines)
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, pageNumbers: null, cancellationToken);
// Extract: returns (Bill, RawLines) - pass stream directly
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
// Map Codecrete Bill to DTO using AutoMapper
var billDto = mapper.Map<SwissQrBillDto>(bill);

View File

@@ -5,55 +5,15 @@ namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
/// Ensures PdfStream is not null.
/// </summary>
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
{
public ExtractSwissQrCodeQueryValidator()
{
RuleFor(x => x)
.Must(HasExactlyOneInput)
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Validate Base64 format if provided
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.WithMessage("Invalid Base64 format");
});
// Validate byte array if provided
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes)
.NotEmpty()
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool HasExactlyOneInput(ExtractSwissQrCodeQuery request)
{
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
// XOR: exactly one must be true
return hasPdfBytes ^ hasBase64;
}
private static bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -6,19 +6,14 @@ using MediatR;
namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Query for PDF validation (supports both byte array and Base64 input)
/// Query for PDF validation (Stream-based)
/// </summary>
public record ValidatePdfQuery : IRequest<PdfValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
/// </summary>
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Convert to stream for IPdfProcessor (using MemoryStream)
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidateAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidateAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<PdfValidationResult>(metadata);

View File

@@ -5,55 +5,15 @@ namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Validator for ValidatePdfQuery
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
/// Ensures PdfStream is not null
/// </summary>
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
{
public ValidatePdfQueryValidator()
{
RuleFor(x => x)
.Must(HasExactlyOneInput)
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Validate Base64 format if provided
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.WithMessage("Invalid Base64 format");
});
// Validate byte array if provided
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes)
.NotEmpty()
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool HasExactlyOneInput(ValidatePdfQuery request)
{
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
// XOR: exactly one must be true
return hasPdfBytes ^ hasBase64;
}
private static bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -6,19 +6,14 @@ using MediatR;
namespace DocumentOperator.Application.ValidatePdfA.Queries;
/// <summary>
/// Query for PDF/A validation (supports both byte array and Base64 input)
/// Query for PDF/A validation (Stream-based)
/// </summary>
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper
/// </summary>
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Convert to stream for IPdfProcessor
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidatePdfAAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidatePdfAAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<PdfAValidationResult>(metadata);

View File

@@ -4,45 +4,15 @@ namespace DocumentOperator.Application.ValidatePdfA.Validators;
/// <summary>
/// Validator for ValidatePdfAQuery
/// Ensures exactly ONE input format is provided (PdfBytes XOR Base64Pdf)
/// Ensures PdfStream is not null
/// </summary>
public class ValidatePdfAQueryValidator : AbstractValidator<Queries.ValidatePdfAQuery>
{
public ValidatePdfAQueryValidator()
{
RuleFor(x => x)
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
!string.IsNullOrWhiteSpace(x.Base64Pdf))
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf!)
.Must(BeValidBase64)
.WithMessage("Base64Pdf must be a valid Base64 string");
});
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes!)
.Must(bytes => bytes.Length > 0)
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool BeValidBase64(string base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -1,35 +0,0 @@
namespace DocumentOperator.Domain.Common.Exceptions;
/// <summary>
/// Exception thrown when PDF processing operations fail.
/// Maps to HTTP 500 Internal Server Error or 422 Unprocessable Entity in the API layer.
/// </summary>
[Obsolete("This exception is deprecated. Use more specific exceptions for PDF processing errors.")]
public class PdfProcessingException : DomainException
{
public string Operation { get; }
public PdfProcessingException(string operation, string message)
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR")
{
Operation = operation;
}
public PdfProcessingException(string operation, string message, Exception innerException)
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR", innerException)
{
Operation = operation;
}
public PdfProcessingException(string message)
: base(message, "PDF_PROCESSING_ERROR")
{
Operation = "Unknown";
}
public PdfProcessingException(string message, Exception innerException)
: base(message, "PDF_PROCESSING_ERROR", innerException)
{
Operation = "Unknown";
}
}

View File

@@ -1,23 +0,0 @@
namespace DocumentOperator.Domain.Exceptions;
/// <summary>
/// Exception thrown when a Swiss QR Code cannot be found in a PDF document.
/// </summary>
[Obsolete("This exception is deprecated. Use SwissQrCodeNotFoundException instead.")]
public sealed class SwissQrCodeNotFoundException : Exception
{
public SwissQrCodeNotFoundException()
: base("No Swiss QR Code found on the last page of the PDF document.")
{
}
public SwissQrCodeNotFoundException(string message)
: base(message)
{
}
public SwissQrCodeNotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,18 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Coordinate origin point for PDF annotations.
/// PDF default is BottomLeft, but users may prefer TopLeft for easier UI integration.
/// </summary>
public enum AnnotationOrigin
{
/// <summary>
/// Bottom-left corner (PDF native coordinate system, default)
/// </summary>
BottomLeft,
/// <summary>
/// Top-left corner (common in UI frameworks)
/// </summary>
TopLeft
}

View File

@@ -0,0 +1,32 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Supported PDF annotation types
/// </summary>
public enum AnnotationType
{
/// <summary>
/// Text markup annotation (highlight, underline, strikeout)
/// </summary>
TextMarkup,
/// <summary>
/// Free text annotation (text box with visible text)
/// </summary>
FreeText,
/// <summary>
/// Sticky note annotation (popup comment icon)
/// </summary>
StickyNote,
/// <summary>
/// Circle shape annotation
/// </summary>
Circle,
/// <summary>
/// Square shape annotation
/// </summary>
Square
}

View File

@@ -0,0 +1,32 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Predefined stamp types with standard text and styling.
/// </summary>
public enum PredefinedStampType
{
/// <summary>
/// CONFIDENTIAL stamp (red, bold).
/// </summary>
Confidential,
/// <summary>
/// APPROVED stamp (green, bold).
/// </summary>
Approved,
/// <summary>
/// DRAFT stamp (gray, italic).
/// </summary>
Draft,
/// <summary>
/// VOID stamp (red, strikethrough effect).
/// </summary>
Void,
/// <summary>
/// FOR REVIEW stamp (orange, bold).
/// </summary>
ForReview
}

View File

@@ -0,0 +1,17 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Specifies whether the stamp should appear in the foreground (on top of content) or background (behind content).
/// </summary>
public enum StampPlacement
{
/// <summary>
/// Stamp appears on top of existing page content.
/// </summary>
Foreground,
/// <summary>
/// Stamp appears behind existing page content (watermark effect).
/// </summary>
Background
}

View File

@@ -0,0 +1,22 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Specifies the type of stamp to add to a PDF document.
/// </summary>
public enum StampType
{
/// <summary>
/// Text-based stamp with custom text, font, and color.
/// </summary>
Text,
/// <summary>
/// Image-based stamp (PNG/JPEG overlay).
/// </summary>
Image,
/// <summary>
/// Predefined stamp with standard text (e.g., CONFIDENTIAL, APPROVED).
/// </summary>
Predefined
}

View File

@@ -0,0 +1,23 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// Text markup annotation style (highlight, underline, strikeout)
/// Maps to DevExpress PdfTextMarkupAnnotationType
/// </summary>
public enum TextMarkupStyle
{
/// <summary>
/// Highlight text with background color
/// </summary>
Highlight,
/// <summary>
/// Underline text
/// </summary>
Underline,
/// <summary>
/// Strikeout text (strikethrough)
/// </summary>
Strikeout
}

View File

@@ -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;
@@ -28,6 +30,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Read stream to byte array for raw data analysis
// (DevExpress needs byte[] for some operations like attachment detection)
byte[] pdfBytes;
@@ -39,14 +47,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
else
{
// Slow path: copy stream to byte array
pdfStream.Position = 0;
using var memoryStream = new MemoryStream();
await pdfStream.CopyToAsync(memoryStream);
pdfBytes = memoryStream.ToArray();
}
// 3. Load PDF with DevExpress Document API
pdfStream.Position = 0;
// Reset position for DevExpress (seekable streams only)
if (pdfStream.CanSeek)
{
pdfStream.Position = 0;
}
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -87,6 +99,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Read stream to byte array for raw data analysis
byte[] pdfBytes;
if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
@@ -95,14 +113,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
}
else
{
pdfStream.Position = 0;
using var memoryStream = new MemoryStream();
await pdfStream.CopyToAsync(memoryStream);
pdfBytes = memoryStream.ToArray();
}
// 3. Load PDF with DevExpress Document API
pdfStream.Position = 0;
// Reset position for DevExpress (seekable streams only)
if (pdfStream.CanSeek)
{
pdfStream.Position = 0;
}
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -175,8 +197,15 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// 2. Load PDF with DevExpress Document API (exceptions propagate naturally)
pdfStream.Position = 0;
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Load PDF with DevExpress Document API
// DevExpress LoadDocument may throw exceptions for corrupted PDFs - let them propagate naturally
// Middleware will catch and convert to 500 Internal Server Error
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -207,6 +236,420 @@ public class DevExpressPdfProcessor : IPdfProcessor
);
}
/// <summary>
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
/// Uses DevExpress PdfDocument.FileAttachments to retrieve attachment data.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>ZIP archive byte array containing all extracted attachments</returns>
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
/// <exception cref="NotFoundException">Thrown when PDF contains no attachments</exception>
public async Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream)
{
// 1. Input Validation
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
{
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Load PDF with DevExpress Document API
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
var document = processor.Document;
// 3. Extract attachment data using DevExpress FileAttachments collection
var fileAttachments = document.FileAttachments;
// 4. No attachments case
if (fileAttachments == null || !fileAttachments.Any())
{
throw new NotFoundException("PDF does not contain any attachments");
}
// 5. Create ZIP archive in memory
using var zipStream = new MemoryStream();
using (var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var attachment in fileAttachments)
{
// Get attachment metadata
string fileName = attachment.FileName ?? "unnamed";
byte[] fileData = attachment.Data;
// Create entry in ZIP
var entry = zipArchive.CreateEntry(fileName, System.IO.Compression.CompressionLevel.Optimal);
// Write attachment data to ZIP entry
using var entryStream = entry.Open();
await entryStream.WriteAsync(fileData, 0, fileData.Length);
}
}
// 6. Return ZIP byte array
return zipStream.ToArray();
}
#endregion
#region PDF Merge Operations
public async Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null)
{
// 1. Validate input: minimum 2 PDFs required
if (pdfStreams == null || pdfStreams.Count < 2)
throw new BadRequestException("At least 2 PDF files are required for merging");
// 2. Validate page ranges length (if provided)
if (pageRanges != null && pageRanges.Count != pdfStreams.Count)
throw new BadRequestException($"Page ranges count ({pageRanges.Count}) must match PDF files count ({pdfStreams.Count})");
// 3. Defensive validation: all streams must be at Position = 0
for (int i = 0; i < pdfStreams.Count; i++)
{
var stream = pdfStreams[i];
if (stream == null)
throw new BadRequestException($"PDF stream at index {i} is null");
if (stream.Length == 0)
throw new BadRequestException($"PDF stream at index {i} is empty");
if (stream.CanSeek && stream.Position != 0)
throw new BadRequestException(null, new ArgumentException(
$"PDF stream at index {i} must be positioned at the beginning (Position = 0).",
nameof(pdfStreams)));
}
// 4. Create merged PDF using DevExpress
using var mergedProcessor = new PdfDocumentProcessor();
// Load first PDF as base document
mergedProcessor.LoadDocument(pdfStreams[0]);
// Apply page range to first PDF if specified
if (pageRanges != null && !string.IsNullOrWhiteSpace(pageRanges[0]))
{
var pageIndices = ParsePageRange(pageRanges[0]!, mergedProcessor.Document.Pages.Count);
// Remove pages not in range (process in reverse to maintain indices)
for (int i = mergedProcessor.Document.Pages.Count - 1; i >= 0; i--)
{
if (!pageIndices.Contains(i))
mergedProcessor.Document.Pages.RemoveAt(i);
}
}
// Append remaining PDFs
for (int i = 1; i < pdfStreams.Count; i++)
{
string? pageRange = pageRanges?[i];
if (string.IsNullOrWhiteSpace(pageRange))
{
// Append all pages
mergedProcessor.AppendDocument(pdfStreams[i]);
}
else
{
// Parse page range and append selected pages
// Note: We need to load the document first to validate page range
using var tempProcessor = new PdfDocumentProcessor();
tempProcessor.LoadDocument(pdfStreams[i]);
var pageIndices = ParsePageRange(pageRange, tempProcessor.Document.Pages.Count);
// DevExpress AppendDocument doesn't support arbitrary page selection
// Workaround: Create temp PDF with selected pages, then append
using var tempStream = new MemoryStream();
// Remove unwanted pages from temp document (in reverse order)
for (int j = tempProcessor.Document.Pages.Count - 1; j >= 0; j--)
{
if (!pageIndices.Contains(j))
tempProcessor.Document.Pages.RemoveAt(j);
}
tempProcessor.SaveDocument(tempStream);
tempStream.Position = 0;
mergedProcessor.AppendDocument(tempStream);
}
}
// 5. Save merged PDF to byte array
using var outputStream = new MemoryStream();
mergedProcessor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
#endregion
#region PDF Annotation
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </summary>
public async Task<byte[]> AddAnnotationAsync(
Stream pdfStream,
Domain.Models.ValueObjects.AnnotationType annotationType,
int pageNumber,
(double X1, double Y1, double X2, double Y2) rectangle,
string? content = null,
string? author = null,
string? color = 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));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException($"PDF stream must be at position 0 (current position: {pdfStream.Position})");
if (pageNumber < 1)
throw new BadRequestException($"Page number must be >= 1 (provided: {pageNumber})");
// Validate content requirement
if (annotationType is Domain.Models.ValueObjects.AnnotationType.FreeText
or Domain.Models.ValueObjects.AnnotationType.StickyNote)
{
if (string.IsNullOrWhiteSpace(content))
throw new BadRequestException($"{annotationType} annotation requires content");
}
// Validate TextMarkup style requirement
if (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup && textMarkupStyle == null)
throw new BadRequestException("TextMarkup annotation requires textMarkupStyle parameter");
byte[] annotatedPdfBytes;
try
{
// 2. Load PDF
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
// 3. Validate page number
int pageCount = processor.Document.Pages.Count;
if (pageNumber > pageCount)
throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})");
// 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];
// 6. Create annotation rectangle from converted coordinates
var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2);
// 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
// 8. Add annotation based on type
switch (annotationType)
{
case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.FreeText:
AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.StickyNote:
AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.Circle:
AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.Square:
AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break;
default:
throw new BadRequestException($"Unsupported annotation type: {annotationType}");
}
// 8. Save annotated PDF
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
annotatedPdfBytes = outputStream.ToArray();
}
catch (BadRequestException)
{
throw; // Re-throw our own exceptions
}
catch (Exception ex)
{
throw new BadRequestException($"Failed to add annotation: {ex.Message}");
}
return await Task.FromResult(annotatedPdfBytes);
}
private void AddTextMarkupAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
Domain.Models.ValueObjects.TextMarkupStyle style,
string? content,
string? author,
PdfRGBColor color)
{
// Map our enum to DevExpress enum
var devExpressStyle = style switch
{
Domain.Models.ValueObjects.TextMarkupStyle.Highlight => PdfTextMarkupAnnotationType.Highlight,
Domain.Models.ValueObjects.TextMarkupStyle.Underline => PdfTextMarkupAnnotationType.Underline,
Domain.Models.ValueObjects.TextMarkupStyle.Strikeout => PdfTextMarkupAnnotationType.StrikeOut,
_ => throw new BadRequestException($"Unsupported text markup style: {style}")
};
var annotation = pageFacade.AddTextMarkupAnnotation(rectangle, devExpressStyle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
private void AddFreeTextAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddFreeTextAnnotation(rectangle, content);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
}
}
private void AddStickyNoteAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string content,
string? author,
PdfRGBColor color)
{
// Sticky note uses a point (top-left corner of rectangle)
var point = new PdfPoint(rectangle.Left, rectangle.Top);
var annotation = pageFacade.AddTextAnnotation(point);
if (annotation != null)
{
annotation.Color = color;
annotation.Contents = content;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
}
}
private void AddCircleAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string? content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddCircleAnnotation(rectangle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
private void AddSquareAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string? content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddSquareAnnotation(rectangle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
/// <summary>
/// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor
/// </summary>
private PdfRGBColor? ParseColor(string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
return null;
try
{
// Remove '#' if present
hexColor = hexColor.TrimStart('#');
if (hexColor.Length != 6)
throw new BadRequestException($"Color must be 6-digit hex (e.g., 'FF0000'), got: '{hexColor}'");
int r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
int g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
int b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
return new PdfRGBColor(r / 255.0, g / 255.0, b / 255.0);
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid color format: '{hexColor}'. Expected 6-digit hex (e.g., 'FF0000'). Error: {ex.Message}");
}
}
#endregion
#region Private Helpers
@@ -319,5 +762,335 @@ public class DevExpressPdfProcessor : IPdfProcessor
}
}
/// <summary>
/// Parses page range string into list of zero-based page indices.
/// </summary>
/// <param name="pageRange">Page range string (e.g., "1-3,5" or "1,3,5")</param>
/// <param name="totalPages">Total page count in PDF (for validation)</param>
/// <returns>List of zero-based page indices</returns>
/// <exception cref="BadRequestException">Invalid format or page number out of range</exception>
private static List<int> ParsePageRange(string pageRange, int totalPages)
{
var pageIndices = new HashSet<int>(); // Use HashSet to avoid duplicates
try
{
// Split by comma
string[] parts = pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (string part in parts)
{
if (part.Contains('-'))
{
// Range format: "1-3"
string[] rangeParts = part.Split('-', StringSplitOptions.TrimEntries);
if (rangeParts.Length != 2)
throw new BadRequestException($"Invalid page range format: '{part}'. Expected format: '1-3'");
if (!int.TryParse(rangeParts[0], out int start) || !int.TryParse(rangeParts[1], out int end))
throw new BadRequestException($"Invalid page numbers in range: '{part}'");
if (start < 1 || end < 1)
throw new BadRequestException($"Page numbers must be >= 1 in range: '{part}'");
if (start > end)
throw new BadRequestException($"Start page must be <= end page in range: '{part}'");
if (start > totalPages || end > totalPages)
throw new BadRequestException($"Page range '{part}' exceeds document page count ({totalPages})");
// Add pages (convert to zero-based indices)
for (int i = start; i <= end; i++)
pageIndices.Add(i - 1);
}
else
{
// Single page: "5"
if (!int.TryParse(part, out int pageNum))
throw new BadRequestException($"Invalid page number: '{part}'");
if (pageNum < 1)
throw new BadRequestException($"Page number must be >= 1: '{part}'");
if (pageNum > totalPages)
throw new BadRequestException($"Page number {pageNum} exceeds document page count ({totalPages})");
pageIndices.Add(pageNum - 1); // Convert to zero-based index
}
}
}
catch (BadRequestException)
{
throw; // Re-throw BadRequestException as-is
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid page range format: '{pageRange}'. Error: {ex.Message}");
}
if (pageIndices.Count == 0)
throw new BadRequestException($"Page range '{pageRange}' resulted in no pages");
return pageIndices.OrderBy(x => x).ToList(); // Return sorted list
}
#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
}

View File

@@ -24,19 +24,27 @@ public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
/// <inheritdoc />
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
byte[] pdfBytes,
Stream pdfStream,
int[]? pageNumbers = null,
CancellationToken cancellationToken = default)
{
if (pdfBytes.Length == 0)
throw new ArgumentException("PDF document contains no byte data.", nameof(pdfBytes));
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new ArgumentException("PDF stream is empty.", nameof(pdfStream));
// Defensive validation: Seekable streams must be at Position = 0
// Non-seekable streams (e.g., NetworkStream) are not checked
if (pdfStream.CanSeek && pdfStream.Position != 0)
throw new BadRequestException(null, new ArgumentException(
"PDF stream must be positioned at the beginning (Position = 0).",
nameof(pdfStream)));
using var pdfDocument = new PdfDocumentProcessor();
using var pdfStream = new MemoryStream(pdfBytes);
pdfDocument.LoadDocument(pdfStream);
if (pdfDocument.Document.Pages.Count == 0)
throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes));
throw new ArgumentException("PDF document contains no pages.", nameof(pdfStream));
// Determine which pages to scan
int[] pagesToScan = DeterminePageNumbers(pdfDocument.Document.Pages.Count, pageNumbers);

View File

@@ -1,5 +1,5 @@
using DocumentOperator.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -29,7 +29,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
var request = new ExtractSwissQrCodeQuery
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = validPdfBase64
};
@@ -56,7 +56,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractSwissQrCodeQuery
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = "INVALID_BASE64!!!"
};
@@ -66,6 +66,10 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]

View File

@@ -1,4 +1,4 @@
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.API.Controllers; // For CheckPdfAttachmentsRequest DTO
using DocumentOperator.Application.Common.DTOs;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
@@ -54,7 +54,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -75,7 +75,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -102,7 +102,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_CheckAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsQuery { Base64Pdf = "invalid-base64!!!" };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -111,14 +111,15 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_CheckAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsQuery { Base64Pdf = string.Empty };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -127,7 +128,8 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
@@ -233,7 +235,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -248,4 +250,134 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
}
#endregion
#region Extract Attachments Tests (Multipart)
[Fact]
public async Task POST_ExtractAttachments_Multipart_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "test.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_EmptyPdf_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Array.Empty<byte>());
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "empty.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_CorruptedPdf_Returns500()
{
// Arrange
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(corruptedBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "corrupted.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Extract Attachments Tests (Base64)
[Fact]
public async Task POST_ExtractAttachments_Base64_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
}

View File

@@ -0,0 +1,500 @@
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using DocumentOperator.API.Controllers;
using DocumentOperator.Domain.Models.ValueObjects;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace DocumentOperator.Tests.Integration.API;
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PdfOperationsControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
private static Stream LoadTestPdfAsStream(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{fileName}";
return assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}
private static string LoadTestPdfAsBase64(string fileName)
{
using var stream = LoadTestPdfAsStream(fileName);
using var ms = new MemoryStream();
stream.CopyTo(ms);
return Convert.ToBase64String(ms.ToArray());
}
#region Merge Endpoint Tests (existing - keeping for reference)
[Fact]
public async Task MergeFromFiles_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdf1Stream = LoadTestPdfAsStream("valid.pdf");
using var pdf2Stream = LoadTestPdfAsStream("valid.pdf");
var pdf1Content = new StreamContent(pdf1Stream);
var pdf2Content = new StreamContent(pdf2Stream);
pdf1Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
pdf2Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdf1Content, "files", "file1.pdf");
content.Add(pdf2Content, "files", "file2.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
mergedPdf.Length.Should().BeGreaterThan(100); // Sanity check
}
[Fact]
public async Task MergeFromBase64_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = null
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromBase64_WithPageRanges_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = new List<string?> { "1", "1" } // Only first page from each
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromFiles_OnePdf_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "files", "file1.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { "INVALID_BASE64!!!", "ANOTHER_INVALID" }
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidPageRange_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf, base64Pdf },
PageRanges = new List<string?> { "999-1000", null } // Exceeds page count
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromFiles_CorruptedPdf_Returns500()
{
// Arrange
using var content = new MultipartFormDataContent();
byte[] corruptedData = "NOT A PDF FILE"u8.ToArray();
var corruptedContent = new ByteArrayContent(corruptedData);
corruptedContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var validPdfStream = LoadTestPdfAsStream("valid.pdf");
var validContent = new StreamContent(validPdfStream);
validContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(corruptedContent, "files", "corrupted.pdf");
content.Add(validContent, "files", "valid.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Annotate Endpoint Tests
[Fact]
public async Task AnnotateFromFile_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.TextMarkup.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("100"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("120"), "y2");
content.Add(new StringContent("Important text"), "content");
content.Add(new StringContent("Test Author"), "author");
content.Add(new StringContent("FFFF00"), "color");
content.Add(new StringContent(TextMarkupStyle.Highlight.ToString()), "textMarkupStyle");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
annotatedPdf.Length.Should().BeGreaterThan(100);
}
[Fact]
public async Task AnnotateFromBase64_FreeText_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 50,
Y1 = 50,
X2 = 150,
Y2 = 100,
Content = "Free text annotation",
Author = "John Doe",
Color = "FF0000"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_StickyNote_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.StickyNote,
PageNumber = 1,
X1 = 300,
Y1 = 300,
X2 = 320,
Y2 = 320,
Content = "Please review this section",
Author = "Reviewer"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromFile_Circle_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.Circle.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("200"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("300"), "y2");
content.Add(new StringContent("Circle annotation"), "content");
content.Add(new StringContent("00FF00"), "color");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_Square_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Square,
PageNumber = 1,
X1 = 250,
Y1 = 250,
X2 = 350,
Y2 = 350,
Color = "0000FF"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new AddAnnotationBase64Command
{
Base64Pdf = "INVALID_BASE64!!!",
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidPageNumber_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 999, // Exceeds page count
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Content = "Test"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_FreeTextWithoutContent_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required Content
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_TextMarkupWithoutStyle_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required TextMarkupStyle
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidColorFormat_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Color = "INVALID" // Invalid hex format
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}

View File

@@ -1,6 +1,5 @@
using DocumentOperator.API.Controllers; // For Base64Request DTOs
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -44,7 +43,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfQuery { Base64Pdf = base64Pdf };
var request = new ValidatePdfBase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -63,7 +62,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
var request = new ValidatePdfBase64Request { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -72,14 +71,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = string.Empty }; // Leerer String
var request = new ValidatePdfBase64Request { Base64Pdf = string.Empty }; // Leerer String
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -88,7 +88,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
@@ -191,7 +192,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfAQuery { Base64Pdf = base64Pdf };
var request = new ValidatePdfABase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -210,7 +211,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_ValidatePdfA_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfAQuery { Base64Pdf = "invalid-base64!!!" };
var request = new ValidatePdfABase64Request { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -219,14 +220,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfAQuery { Base64Pdf = string.Empty };
var request = new ValidatePdfABase64Request { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -235,7 +237,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion

View File

@@ -30,7 +30,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(
hasAttachments: true,
@@ -72,8 +72,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
string base64Pdf = Convert.ToBase64String(pdfBytes);
var query = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(false, 0, []);
var expectedDto = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
@@ -97,7 +96,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(false, 0, []);
var expectedDto = new AttachmentCheckResult

View File

@@ -27,7 +27,7 @@ public class ValidatePdfHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfMetadata(
pageCount: 5,
@@ -74,7 +74,7 @@ public class ValidatePdfHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
_mockPdfProcessor
.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))

View File

@@ -28,7 +28,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfAMetadata(
isValid: true,
@@ -86,7 +86,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { Base64Pdf = Convert.ToBase64String(pdfBytes) };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var errors = new List<string> { "Missing XMP metadata", "Invalid color space" };
var warnings = new List<string> { "Embedded font not subset" };
@@ -144,7 +144,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfAMetadata(
isValid: true,
@@ -196,7 +196,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
_mockPdfProcessor
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))

View File

@@ -47,6 +47,17 @@ public class DevExpressPdfProcessorTests
return memoryStream.ToArray();
}
/// <summary>
/// Loads a test PDF from embedded resources as Stream.
/// </summary>
/// <param name="filename">Name of the PDF file (e.g., "valid.pdf")</param>
/// <returns>PDF content as MemoryStream (caller must dispose)</returns>
private static MemoryStream LoadTestPdfAsStream(string filename)
{
byte[] pdfBytes = LoadTestPdf(filename);
return new MemoryStream(pdfBytes);
}
/// <summary>
/// Converts byte array to MemoryStream for testing
/// </summary>
@@ -275,8 +286,531 @@ public class DevExpressPdfProcessorTests
}
#endregion
#region ExtractAttachmentsAsync Tests
[Fact]
public async Task ExtractAttachmentsAsync_PdfWithAttachments_ReturnsValidZip()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP header (PK signature)
zipBytes[0].Should().Be(0x50, "ZIP magic bytes start with 'P'");
zipBytes[1].Should().Be(0x4B, "ZIP magic bytes continue with 'K'");
// Verify ZIP can be opened
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact(Skip = "valid.pdf may contain attachments - need dedicated PDF without attachments for this test")]
public async Task ExtractAttachmentsAsync_PdfWithoutAttachments_ThrowsNotFoundException()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf"); // No attachments
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*attachments*", "PDF without attachments should throw NotFoundException");
}
[Fact]
public async Task ExtractAttachmentsAsync_ExtractedFilesHaveCorrectNames()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
// Verify all entries have valid names (not null/empty)
foreach (var entry in zipArchive.Entries)
{
entry.Name.Should().NotBeNullOrEmpty("each file should have a name");
}
}
[Fact]
public async Task ExtractAttachmentsAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange
using var emptyStream = new MemoryStream();
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(emptyStream);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*", "empty stream should be rejected");
}
[Fact]
public async Task ExtractAttachmentsAsync_CorruptedPdf_ThrowsException()
{
// Arrange
byte[] pdfBytes = "This is not a valid PDF content"u8.ToArray();
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
// DevExpress throws exception for invalid PDF data
await act.Should().ThrowAsync<Exception>("corrupted PDF should throw exception");
}
#endregion
#region MergePdfsAsync Tests
[Fact]
public async Task MergePdfsAsync_TwoPdfs_ReturnsMergedPdf()
{
// Arrange
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
// Assert
mergedPdf.Should().NotBeEmpty("merged PDF should contain data");
mergedPdf.Length.Should().BeGreaterThan(1000, "merged PDF should be reasonably sized");
// Verify it's a valid PDF by loading it
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "merged PDF should have at least 2 pages");
}
[Fact]
public async Task MergePdfsAsync_ThreePdfs_ReturnsMergedPdf()
{
// Arrange
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
using var pdf3 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf");
var streams = new List<Stream> { pdf1, pdf2, pdf3 };
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(3, "merged PDF should have at least 3 pages");
}
[Fact]
public async Task MergePdfsAsync_WithPageRanges_MergesSelectedPages()
{
// Arrange: Merge first page of each PDF
using var pdf1 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf"); // Multi-page
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // Multi-page
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1", "1" }; // Only first page from each
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF has exactly 2 pages
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().Be(2, "should have exactly 2 pages (1 from each PDF)");
}
[Fact]
public async Task MergePdfsAsync_WithRangeFormat_MergesSelectedPages()
{
// Arrange: Merge 2 PDFs, second PDF with page range "1" (first page only)
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // PDF with attachments
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { null, "1" }; // All pages from first, page 1 only from second
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF has at least 2 pages (1 from first + 1 from second)
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "should have at least 2 pages");
}
[Fact]
public async Task MergePdfsAsync_SinglePdf_ThrowsBadRequestException()
{
// Arrange: Only one PDF (minimum 2 required)
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
var streams = new List<Stream> { pdf1 };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*at least 2*");
}
[Fact]
public async Task MergePdfsAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange: One valid PDF + one empty stream
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var emptyStream = new MemoryStream();
var streams = new List<Stream> { pdf1, emptyStream };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*");
}
[Fact]
public async Task MergePdfsAsync_StreamNotAtBeginning_ThrowsBadRequestException()
{
// Arrange: Valid PDFs but second stream not at Position=0
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
pdf2.Position = 10; // Move position away from beginning
var streams = new List<Stream> { pdf1, pdf2 };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>();
}
[Fact]
public async Task MergePdfsAsync_InvalidPageRange_ThrowsBadRequestException()
{
// Arrange: Invalid page range format
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1", "abc" }; // Invalid format
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*Invalid page*");
}
[Fact]
public async Task MergePdfsAsync_PageRangeExceedsPageCount_ThrowsBadRequestException()
{
// Arrange: Page range exceeds document page count
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1-99", null }; // 99 exceeds pdf1 page count
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*exceeds document page count*");
}
[Fact]
public async Task MergePdfsAsync_PageRangesMismatch_ThrowsBadRequestException()
{
// Arrange: Page ranges count doesn't match streams count
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1" }; // Only 1 range for 2 PDFs
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*must match*");
}
#endregion
#region AddAnnotationAsync Tests
[Fact]
public async Task AddAnnotationAsync_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.TextMarkup;
var rectangle = (X1: 100.0, Y1: 100.0, X2: 200.0, Y2: 120.0);
var textMarkupStyle = Domain.Models.ValueObjects.TextMarkupStyle.Highlight;
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Important text",
author: "Test Author",
color: "FFFF00", // Yellow
textMarkupStyle);
// Assert
result.Should().NotBeEmpty();
result.Length.Should().BeGreaterThan((int)pdfStream.Length); // Annotation adds bytes
}
[Fact]
public async Task AddAnnotationAsync_FreeText_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.FreeText;
var rectangle = (X1: 50.0, Y1: 50.0, X2: 150.0, Y2: 100.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Free text annotation",
author: "John Doe",
color: "FF0000"); // Red
// Assert
result.Should().NotBeEmpty();
result.Length.Should().BeGreaterThan((int)pdfStream.Length);
}
[Fact]
public async Task AddAnnotationAsync_StickyNote_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.StickyNote;
var rectangle = (X1: 300.0, Y1: 300.0, X2: 320.0, Y2: 320.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Please review this section",
author: "Reviewer");
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_Circle_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.Circle;
var rectangle = (X1: 100.0, Y1: 200.0, X2: 200.0, Y2: 300.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Circle annotation",
color: "00FF00"); // Green
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_Square_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.Square;
var rectangle = (X1: 250.0, Y1: 250.0, X2: 350.0, Y2: 350.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
color: "0000FF"); // Blue
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange
using var emptyStream = new MemoryStream();
// Act
var act = async () => await _sut.AddAnnotationAsync(
emptyStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
content: "Test");
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*cannot be empty*");
}
[Fact]
public async Task AddAnnotationAsync_StreamNotAtPositionZero_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
pdfStream.Position = 10; // Move stream position
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.Circle,
pageNumber: 1,
rectangle: (0, 0, 100, 100));
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*must be at position 0*");
}
[Fact]
public async Task AddAnnotationAsync_InvalidPageNumber_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 999, // Exceeds page count
rectangle: (0, 0, 100, 100),
content: "Test");
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*exceeds document page count*");
}
[Fact]
public async Task AddAnnotationAsync_FreeTextWithoutContent_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
content: null); // Missing required content
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires content*");
}
[Fact]
public async Task AddAnnotationAsync_StickyNoteWithoutContent_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.StickyNote,
pageNumber: 1,
rectangle: (0, 0, 20, 20),
content: ""); // Empty content
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires content*");
}
[Fact]
public async Task AddAnnotationAsync_TextMarkupWithoutStyle_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.TextMarkup,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
textMarkupStyle: null); // Missing required style
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires textMarkupStyle*");
}
[Fact]
public async Task AddAnnotationAsync_InvalidColorFormat_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.Circle,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
color: "INVALID"); // Invalid hex format
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*Invalid color format*");
}
#endregion
}

View File

@@ -1,7 +1,6 @@
using System.Reflection;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Exceptions;
using DocumentOperator.Infrastructure.Services.QrCodeProcessing;
using FluentAssertions;
@@ -24,16 +23,16 @@ public class DevExpressSwissQrCodeProcessorTests
#region Helper Methods
/// <summary>
/// Loads a test PDF from embedded resources.
/// Loads a test PDF from embedded resources as a Stream.
/// </summary>
/// <param name="filename">Name of the PDF file (e.g., "pdfWithSwissQRCode.pdf")</param>
/// <returns>PDF content as byte array</returns>
private static byte[] LoadTestPdf(string filename)
/// <returns>PDF content as MemoryStream</returns>
private static Stream LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
@@ -42,9 +41,11 @@ public class DevExpressSwissQrCodeProcessorTests
$"Available resources: {string.Join(", ", assembly.GetManifestResourceNames())}");
}
using var memoryStream = new MemoryStream();
// Copy to MemoryStream so caller can reuse/seek
var memoryStream = new MemoryStream();
stream.CopyTo(memoryStream);
return memoryStream.ToArray();
memoryStream.Position = 0; // Reset position for reading
return memoryStream;
}
#endregion
@@ -55,10 +56,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsQrCodeData()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, rawLines) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, rawLines) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Should().NotBeNull("PDF contains a Swiss QR Code");
@@ -73,10 +74,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsValidIban()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Account.Should().MatchRegex(@"^CH\d{2}[A-Z0-9]{17}$",
@@ -87,10 +88,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsCurrency()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Currency.Should().BeOneOf("CHF", "EUR",
@@ -101,10 +102,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_PdfWithoutQrCode_ThrowsSwissQrCodeNotFoundException()
{
// Arrange: valid.pdf doesn't contain a Swiss QR Code
byte[] pdfBytes = LoadTestPdf("valid.pdf");
using var pdfStream = LoadTestPdf("valid.pdf");
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*No valid Swiss QR Code found*");
@@ -113,11 +114,11 @@ public class DevExpressSwissQrCodeProcessorTests
[Fact]
public async Task ExtractSwissQrCodeAsync_EmptyPdf_ThrowsException()
{
// Arrange: Empty byte array
byte[] emptyPdfBytes = [];
// Arrange: Empty stream
using var emptyStream = new MemoryStream();
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(emptyPdfBytes);
var act = async () => await _sut.ExtractSwissQrCodeAsync(emptyStream);
await act.Should().ThrowAsync<Exception>()
.Where(ex => ex is ArgumentException);
@@ -127,26 +128,39 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_NullInput_ThrowsArgumentNullException()
{
// Arrange
byte[] nullPdfBytes = null!;
Stream nullStream = null!;
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(nullPdfBytes);
var act = async () => await _sut.ExtractSwissQrCodeAsync(nullStream);
await act.Should().ThrowAsync<NullReferenceException>();
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_InvalidPdfBytes_ThrowsPdfProcessingException()
{
// Arrange: Random bytes that are not a valid PDF
byte[] invalidPdfBytes = "This is not a PDF file"u8.ToArray();
using var invalidStream = new MemoryStream("This is not a PDF file"u8.ToArray());
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(invalidPdfBytes);
var act = async () => await _sut.ExtractSwissQrCodeAsync(invalidStream);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_StreamNotAtBeginning_ThrowsBadRequestException()
{
// Arrange: Valid PDF but stream position is not at 0
using var pdfStream = LoadTestPdf("valid.pdf");
pdfStream.Position = 10; // Move position away from beginning
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
await act.Should().ThrowAsync<BadRequestException>();
}
#endregion
#region Swiss QR Code Content Validation Tests
@@ -155,10 +169,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesCreditorInformation()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Creditor.Should().NotBeNull("Creditor information is required");
@@ -170,10 +184,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesDebtorInformationIfPresent()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
// Debtor information is OPTIONAL in Swiss QR Bill Standard 2.0
@@ -190,10 +204,10 @@ public class DevExpressSwissQrCodeProcessorTests
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesAmountIfPresent()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
// Amount is OPTIONAL in Swiss QR Bill (can be 0.00 or null for payment slips)

321
FEATURE_7_PLAN.md Normal file
View File

@@ -0,0 +1,321 @@
# Feature 7 Plan: PDF Stamp Operation
## Overview
**Priority:** Phase 2, Priority 6
**Endpoint:** `POST /api/pdf/operations/stamp`
**Purpose:** Add text/image stamps to PDF documents (watermarks, confidential marks, approval stamps)
---
## Requirements (from CONTROLLER_ENDPOINTS.md)
### Endpoint Specification
- **Route:** `/api/pdf/operations/stamp`
- **Method:** POST
- **Input formats:**
- Multipart/form-data (PDF file + stamp parameters)
- JSON (Base64-encoded PDF + stamp parameters)
- **Output:** Stamped PDF file (application/pdf)
### Stamp Types
1. **Text Stamp:** Custom text with font, size, color, rotation
2. **Image Stamp:** PNG/JPEG image overlay
3. **Predefined Stamps:** "CONFIDENTIAL", "APPROVED", "DRAFT", "VOID" (optional)
### Stamp Parameters
- **Position:** X, Y coordinates (with Origin support: BottomLeft/TopLeft)
- **Size:** Width, Height (optional if using image natural size)
- **Rotation:** Angle in degrees (0-360)
- **Opacity:** 0.0 (transparent) to 1.0 (opaque)
- **Page range:** Single page, multiple pages, or "all pages"
---
## Implementation Plan (7 Systematic Commits)
### Commit 1: Domain Layer
**File:** `DocumentOperator.Domain/Models/ValueObjects/StampType.cs`
```csharp
public enum StampType
{
Text,
Image,
Predefined
}
```
**File:** `DocumentOperator.Domain/Models/ValueObjects/PredefinedStampType.cs`
```csharp
public enum PredefinedStampType
{
Confidential,
Approved,
Draft,
Void,
ForReview
}
```
**File:** `DocumentOperator.Domain/Models/ValueObjects/StampPlacement.cs`
```csharp
public enum StampPlacement
{
Foreground, // On top of content
Background // Behind content
}
```
---
### Commit 2: Infrastructure Interface
**File:** `DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs`
Add method:
```csharp
/// <summary>
/// Adds a stamp (text or image) to specified pages of a PDF document.
/// </summary>
Task<byte[]> AddStampAsync(
Stream pdfStream,
StampType stampType,
int[]? pageNumbers, // null = all pages
(double X, double Y) position,
(double Width, double Height)? size = null, // null = auto-size for image
AnnotationOrigin origin = AnnotationOrigin.BottomLeft,
string? text = null, // Required for Text stamp
string? fontName = null, // Default: Arial
double? fontSize = null, // Default: 12
string? color = null, // Hex color, default: "000000" (black)
double? opacity = null, // 0.0-1.0, default: 0.5
double? rotation = null, // Degrees, default: 0
StampPlacement placement = StampPlacement.Foreground,
byte[]? imageBytes = null, // Required for Image stamp
PredefinedStampType? predefinedType = null); // Required for Predefined stamp
```
**Validation rules:**
- `text` required if `stampType == Text`
- `imageBytes` required if `stampType == Image`
- `predefinedType` required if `stampType == Predefined`
- `pageNumbers` can be null (all pages), empty array not allowed
- `opacity` must be 0.0-1.0
- `rotation` must be 0-360
---
### Commit 3: Infrastructure Implementation
**File:** `DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs`
**DevExpress API:**
- Use `PdfGraphics` to draw on page
- `PdfGraphics.DrawString()` for text stamps
- `PdfGraphics.DrawImage()` for image stamps
- Set opacity via `PdfBrush.Color` alpha channel
- Apply rotation via `PdfGraphics.RotateTransform()`
**Implementation strategy:**
1. Load PDF
2. Validate page numbers against document page count
3. For each target page:
- Get `PdfGraphics` from `PdfDocumentProcessor.CreateGraphics()`
- Convert coordinates if `origin == TopLeft`
- Apply rotation transform
- Set opacity via brush alpha
- Draw text/image
- Dispose graphics
4. Save to byte array
**Private helper methods:**
- `AddTextStamp(PdfGraphics graphics, ...)`
- `AddImageStamp(PdfGraphics graphics, byte[] imageBytes, ...)`
- `AddPredefinedStamp(PdfGraphics graphics, PredefinedStampType type, ...)` (delegates to AddTextStamp with predefined text/style)
- `CalculatePageNumbers(int totalPages, int[]? requestedPages)` → returns array of zero-based indices
---
### Commit 4: Infrastructure Unit Tests
**File:** `DocumentOperator.Tests/Unit/Infrastructure/Services/PdfProcessing/DevExpressPdfProcessorTests.cs`
**Tests (10-12):**
1. `AddStampAsync_TextStampSinglePage_ReturnsStampedPdf`
2. `AddStampAsync_TextStampAllPages_ReturnsStampedPdf`
3. `AddStampAsync_ImageStampWithRotation_ReturnsStampedPdf`
4. `AddStampAsync_PredefinedStampConfidential_ReturnsStampedPdf`
5. `AddStampAsync_WithOpacity_ReturnsStampedPdf`
6. `AddStampAsync_TopLeftOrigin_ReturnsStampedPdf`
7. `AddStampAsync_EmptyStream_ThrowsBadRequestException`
8. `AddStampAsync_InvalidPageNumber_ThrowsBadRequestException`
9. `AddStampAsync_TextStampWithoutText_ThrowsBadRequestException`
10. `AddStampAsync_ImageStampWithoutBytes_ThrowsBadRequestException`
11. `AddStampAsync_InvalidOpacity_ThrowsBadRequestException`
12. `AddStampAsync_InvalidRotation_ThrowsBadRequestException`
---
### Commit 5: Application Layer
**File:** `DocumentOperator.Application/AddStamp/AddStampCommand.cs`
**Command/Handler/Validator merged:**
```csharp
public record AddStampCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required StampType StampType { get; init; }
public int[]? PageNumbers { get; init; } // null = all pages
public required (double X, double Y) Position { get; init; }
public (double Width, double Height)? Size { get; init; }
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
public string? Text { get; init; }
public string? FontName { get; init; }
public double? FontSize { get; init; }
public string? Color { get; init; }
public double? Opacity { get; init; }
public double? Rotation { get; init; }
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
public byte[]? ImageBytes { get; init; }
public PredefinedStampType? PredefinedType { get; init; }
}
```
**Validator:**
- Stream not null
- Text required if StampType == Text
- ImageBytes required if StampType == Image
- PredefinedType required if StampType == Predefined
- Color regex: `^[0-9A-Fa-f]{6}$`
- Opacity: 0.0-1.0
- Rotation: 0-360
- FontSize > 0 if provided
---
### Commit 6: API Endpoints + DTOs
**File:** `DocumentOperator.API/Controllers/PdfOperationsController.cs`
**Two endpoints:**
1. **Multipart:** `POST /api/pdf/operations/stamp` (multipart/form-data)
2. **JSON:** `POST /api/pdf/operations/stamp` (application/json)
**DTOs:**
```csharp
public class AddStampMultipartRequest
{
public required IFormFile File { get; set; }
public required StampType StampType { get; set; }
public int[]? PageNumbers { get; set; } // Comma-separated in form: "1,3,5"
public required double X { get; set; }
public required double Y { get; set; }
public double? Width { get; set; }
public double? Height { get; set; }
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
public string? Text { get; set; }
public string? FontName { get; set; }
public double? FontSize { get; set; }
public string? Color { get; set; }
public double? Opacity { get; set; }
public double? Rotation { get; set; }
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
public IFormFile? ImageFile { get; set; } // Optional image for Image stamp
public PredefinedStampType? PredefinedType { get; set; }
}
public record AddStampBase64Request
{
public required string Base64Pdf { get; init; }
public required StampType StampType { get; init; }
public int[]? PageNumbers { get; init; }
public required double X { get; init; }
public required double Y { get; init; }
public double? Width { get; init; }
public double? Height { get; init; }
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
public string? Text { get; init; }
public string? FontName { get; init; }
public double? FontSize { get; init; }
public string? Color { get; init; }
public double? Opacity { get; init; }
public double? Rotation { get; init; }
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
public string? Base64Image { get; init; } // Base64-encoded image bytes
public PredefinedStampType? PredefinedType { get; init; }
}
```
**Endpoint names:**
- `Name = "AddStampFromFile"`
- `Name = "AddStampFromBase64"`
---
### Commit 7: Integration Tests + Documentation
**File:** `DocumentOperator.Tests/Integration/API/PdfOperationsControllerTests.cs`
**Tests (8-10):**
1. `AddStampFromFile_TextStampSinglePage_ReturnsStampedPdf`
2. `AddStampFromBase64_TextStampAllPages_ReturnsStampedPdf`
3. `AddStampFromFile_ImageStamp_ReturnsStampedPdf`
4. `AddStampFromBase64_PredefinedStamp_ReturnsStampedPdf`
5. `AddStampFromBase64_WithRotationAndOpacity_ReturnsStampedPdf`
6. `AddStampFromBase64_InvalidBase64_Returns400`
7. `AddStampFromBase64_TextStampWithoutText_Returns400`
8. `AddStampFromBase64_ImageStampWithoutImage_Returns400`
9. `AddStampFromBase64_InvalidPageNumber_Returns400`
10. `AddStampFromBase64_InvalidOpacity_Returns400`
**Update AGENTS.md:**
- Test count: 101 → ~120 passed
- PdfOperationsController status: 2/3 → 3/3 endpoints (merge + annotate + stamp ALL DONE)
- Add Feature 7 to test breakdown
---
## DevExpress API References
**Key classes:**
- `PdfDocumentProcessor.CreateGraphics(int pageIndex)` → `PdfGraphics`
- `PdfGraphics.DrawString(string text, PdfFont font, PdfBrush brush, RectangleF bounds)`
- `PdfGraphics.DrawImage(Image image, RectangleF bounds)`
- `PdfGraphics.RotateTransform(float angle)`
- `PdfGraphics.SetTransparency(float opacity)` or use `Color.FromArgb(alpha, r, g, b)`
- `PdfFont.Create(string fontName, float fontSize)`
- `PdfBrush.Create(Color color)`
**Coordinate system:**
- Same as annotations: BottomLeft origin by default
- Need to convert if user specifies TopLeft
**Stamp placement:**
- Foreground: Draw after page content (`CreateGraphics` with `PdfGraphicsContentStreamType.Foreground`)
- Background: Draw before page content (`CreateGraphics` with `PdfGraphicsContentStreamType.Background`)
---
## Potential Challenges
1. **Image format support:** DevExpress may require specific image formats (PNG, JPEG). Need to validate.
2. **Font availability:** Custom fonts may not be available on server. Default to Arial.
3. **Text wrapping:** If text is too long, may overflow stamp bounds. Truncate or wrap?
4. **Performance:** Adding stamp to all pages of large PDF (500+ pages) may be slow. Consider async/streaming.
---
## Estimated Effort
- **Complexity:** Medium (similar to annotation, but with graphics drawing)
- **Time:** 4-6 hours (7 commits)
- **Test coverage:** ~20 tests (12 unit + 10 integration)
- **Expected test count after completion:** ~120 passed, 7 skipped
---
## Success Criteria
✅ All 7 commits completed systematically
✅ Build: 0 errors, 0 warnings (ignore DevExpress trial warnings)
✅ Tests: ~120 passed, 7 skipped, 0 failed
✅ Swagger: Both stamp endpoints visible and testable
✅ Documentation: AGENTS.md updated with Feature 7 status
---
**Ready to start when you approve!** 🎉