Refactor: Rename DocumentOperator to DocumentService
Updated project and solution files to rename and restructure `DocumentOperator` to `DocumentService`. Modified project references in `DocumentService.API.csproj`, `DocumentService.Application.csproj`, and `DocumentService.Infrastructure.csproj` to reflect the new naming convention. Updated `DocumentService.sln` to remove old project references, add new ones, and adjust configuration and nested project mappings accordingly. This change aligns the project structure with the new naming convention.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user