This commit implements a complete rebranding of the project: - Updated all namespaces from `DocumentOperator` to `DocumentService`. - Renamed file paths, embedded resources, and test data references. - Updated configuration keys, logging paths, and Redis instance names. - Revised documentation to reflect the new project name. - Modified project and solution files to align with the new structure. - Updated class names, DTOs, commands, queries, and handlers. - Adjusted middleware, controllers, and API endpoints. - Updated Swagger metadata and API titles to `DocumentService API`. - Refactored test namespaces, resource paths, and embedded resources. - Updated build and deployment configurations for the new name. - Replaced all references to `DocumentOperator` in comments and literals. These changes ensure consistency across the codebase and documentation.
166 lines
6.5 KiB
C#
166 lines
6.5 KiB
C#
using Codecrete.SwissQRBill.Generator;
|
|
using DevExpress.Drawing;
|
|
using DevExpress.Pdf;
|
|
using DocumentService.Application.Common.Interfaces;
|
|
using DocumentService.Domain.Common.Exceptions;
|
|
using SkiaSharp;
|
|
using SkiaSharp.QrCode;
|
|
using System.Collections.Concurrent;
|
|
|
|
namespace DocumentService.Infrastructure.Services.QrCodeProcessing;
|
|
|
|
/// <summary>
|
|
/// Swiss QR Code processor using DevExpress PDF API for image extraction,
|
|
/// SkiaSharp.QrCode for QR decoding, and Codecrete.SwissQRBill.Generator for Swiss QR parsing.
|
|
///
|
|
/// Strategy:
|
|
/// 1. Extract all embedded images from PDF using GetDXImages()
|
|
/// 2. Try to decode QR code from each image using SkiaSharp.QrCode
|
|
/// 3. Parse Swiss QR format with Codecrete library
|
|
/// 4. Return both parsed Bill and raw QR text lines
|
|
/// </summary>
|
|
public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
|
|
{
|
|
|
|
/// <inheritdoc />
|
|
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
|
Stream pdfStream,
|
|
int[]? pageNumbers = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
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();
|
|
pdfDocument.LoadDocument(pdfStream);
|
|
|
|
if (pdfDocument.Document.Pages.Count == 0)
|
|
throw new ArgumentException("PDF document contains no pages.", nameof(pdfStream));
|
|
|
|
// Determine which pages to scan
|
|
int[] pagesToScan = DeterminePageNumbers(pdfDocument.Document.Pages.Count, pageNumbers);
|
|
|
|
// Extract all images from specified pages (no pre-filtering)
|
|
var allImages = new ConcurrentBag<(int pageNumber, DXBitmap image)>();
|
|
|
|
foreach (int pageNumber in pagesToScan)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Define area: entire page
|
|
var page = pdfDocument.Document.Pages[pageNumber - 1];
|
|
var area = new PdfDocumentArea(pageNumber,
|
|
new PdfRectangle(0, 0, page.CropBox.Width, page.CropBox.Height));
|
|
|
|
// Extract images from this page
|
|
var images = pdfDocument.GetDXImages(area);
|
|
|
|
foreach (var image in images)
|
|
{
|
|
// Collect ALL images - QRCodeDecoder will determine if it's a QR code
|
|
allImages.Add((pageNumber, image));
|
|
}
|
|
}
|
|
|
|
if (allImages.IsEmpty)
|
|
throw new NotFoundException($"No images found in pages: {string.Join(", ", pagesToScan)}");
|
|
|
|
// Parallel scan all images
|
|
var qrCodeTasks = allImages.Select(imageData =>
|
|
Task.Run(() =>
|
|
{
|
|
using (imageData.image)
|
|
{
|
|
string? qrText = DecodeQrCodeFromImage(imageData.image);
|
|
if (!string.IsNullOrEmpty(qrText))
|
|
{
|
|
try
|
|
{
|
|
// Parse with Codecrete
|
|
var bill = QRBill.DecodeQrCodeText(qrText);
|
|
|
|
// Split raw text into lines (handle both \r\n and \n)
|
|
// Remove leading/trailing \r and \n from each line
|
|
var rawLines = qrText
|
|
.Split(["\r\n", "\n"], StringSplitOptions.None)
|
|
.Select(line => line.Trim('\r', '\n'))
|
|
.ToArray();
|
|
|
|
return (success: true, bill, rawLines, imageData.pageNumber);
|
|
}
|
|
catch
|
|
{
|
|
// Not a valid Swiss QR Bill, ignore
|
|
return (success: false, bill: (Bill?)null, rawLines: (string[]?)null, pageNumber: 0);
|
|
}
|
|
}
|
|
return (success: false, bill: (Bill?)null, rawLines: (string[]?)null, pageNumber: 0);
|
|
}
|
|
}, cancellationToken)
|
|
).ToList();
|
|
|
|
// Wait for all tasks and find first valid Swiss QR
|
|
var results = await Task.WhenAll(qrCodeTasks);
|
|
var validResult = results.FirstOrDefault(r => r.success);
|
|
|
|
if (validResult.success && validResult.bill != null)
|
|
{
|
|
return (validResult.bill, validResult.rawLines!);
|
|
}
|
|
|
|
throw new NotFoundException(
|
|
$"No valid Swiss QR Code found in {allImages.Count} images across pages: {string.Join(", ", pagesToScan)}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines which pages to scan based on optional page numbers parameter.
|
|
/// Default: Last page first, then all pages in reverse order.
|
|
/// </summary>
|
|
private static int[] DeterminePageNumbers(int totalPages, int[]? pageNumbers)
|
|
{
|
|
if (pageNumbers != null && pageNumbers.Length > 0)
|
|
{
|
|
// Validate page numbers
|
|
foreach (int pageNum in pageNumbers)
|
|
if (pageNum < 1 || pageNum > totalPages)
|
|
throw new ArgumentException(
|
|
$"Invalid page number {pageNum}. Document has {totalPages} pages.",
|
|
nameof(pageNumbers));
|
|
return pageNumbers;
|
|
}
|
|
|
|
// Default: Scan all pages, last page first (Swiss QR standard)
|
|
return [.. Enumerable.Range(1, totalPages).OrderByDescending(p => p)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decodes QR code from a DXBitmap image using SkiaSharp.QrCode.
|
|
/// Converts DXBitmap to SKBitmap and attempts decoding.
|
|
/// </summary>
|
|
private static string? DecodeQrCodeFromImage(DXBitmap dxImage)
|
|
{
|
|
// Convert DXBitmap to SKBitmap via MemoryStream (PNG format)
|
|
using var ms = new MemoryStream();
|
|
dxImage.Save(ms, DXImageFormat.Png);
|
|
ms.Position = 0;
|
|
|
|
// Decode using SkiaSharp.QrCode
|
|
using var skBitmap = SKBitmap.Decode(ms);
|
|
if (skBitmap == null)
|
|
return null;
|
|
|
|
// TryDecode returns true if QR code found and decoded successfully
|
|
bool success = QRCodeDecoder.TryDecode(skBitmap, out var text, out _);
|
|
return success ? text : null;
|
|
}
|
|
}
|