refactor: Refactor DevExpressSwissQrCodeProcessor to use Codecrete QRBill parser
- Change return type to tuple (Bill, string[]) - Remove custom parsing methods (ParseSwissQrBillContent, MapAddress, DetermineReferenceType) - Use Codecrete QRBill.DecodeQrCodeText() for parsing - Add SkiaSharp.QrCode v1.0.0 for QR decoding - Remove obsolete ZXing and System.Drawing dependencies - Add StringExtensions for QR code detection - Raw lines properly split and trimmed from QR text
This commit is contained in:
@@ -2,194 +2,162 @@ using Codecrete.SwissQRBill.Generator;
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Exceptions;
|
||||
using DocumentOperator.Domain.ValueObjects;
|
||||
using System.Drawing;
|
||||
using SkiaSharp;
|
||||
using SkiaSharp.QrCode;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.Versioning;
|
||||
using ZXing;
|
||||
|
||||
namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Code processor using DevExpress PDF API for PDF access
|
||||
/// and Codecrete.SwissQRBill.Generator for QR Code parsing.
|
||||
/// 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
|
||||
{
|
||||
private const int QrCodeSearchDpi = 300; // High DPI for better QR code recognition
|
||||
|
||||
/// <inheritdoc />
|
||||
[SupportedOSPlatform("windows")]
|
||||
public async Task<SwissQrCodeData> ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
byte[] pdfBytes,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||
if (pdfBytes.Length == 0)
|
||||
throw new ArgumentException("PDF document contains no byte data.", nameof(pdfBytes));
|
||||
|
||||
try
|
||||
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));
|
||||
|
||||
// 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)
|
||||
{
|
||||
using var pdfDocument = new PdfDocumentProcessor();
|
||||
pdfDocument.LoadDocument(new MemoryStream(pdfBytes));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (pdfDocument.Document.Pages.Count == 0)
|
||||
// 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)
|
||||
{
|
||||
throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes));
|
||||
// Collect ALL images - QRCodeDecoder will determine if it's a QR code
|
||||
allImages.Add((pageNumber, image));
|
||||
}
|
||||
}
|
||||
|
||||
// Get last page
|
||||
int lastPageIndex = pdfDocument.Document.Pages.Count - 1;
|
||||
if (allImages.IsEmpty)
|
||||
throw new NotFoundException($"No images found in pages: {string.Join(", ", pagesToScan)}");
|
||||
|
||||
// Convert last page to image for QR code detection
|
||||
using var pageImage = RenderPageToImage(pdfDocument, lastPageIndex);
|
||||
|
||||
// Detect and decode QR code
|
||||
string? qrCodeContent = DecodeQrCodeFromImage(pageImage);
|
||||
|
||||
if (string.IsNullOrEmpty(qrCodeContent))
|
||||
// Parallel scan all images
|
||||
var qrCodeTasks = allImages.Select(imageData =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
throw new SwissQrCodeNotFoundException(
|
||||
$"No QR Code found on the last page (page {lastPageIndex + 1}) of the PDF document.");
|
||||
}
|
||||
using (imageData.image)
|
||||
{
|
||||
string? qrText = DecodeQrCodeFromImage(imageData.image);
|
||||
if (!string.IsNullOrEmpty(qrText))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse with Codecrete
|
||||
var bill = QRBill.DecodeQrCodeText(qrText);
|
||||
|
||||
// Parse Swiss QR Bill content using Codecrete library
|
||||
SwissQrCodeData qrCodeData = ParseSwissQrBillContent(qrCodeContent);
|
||||
// Split raw text into lines (handle both \r\n and \n)
|
||||
// Remove leading/trailing \r and \n from each line
|
||||
var rawLines = qrText
|
||||
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
|
||||
.Select(line => line.Trim('\r', '\n'))
|
||||
.ToArray();
|
||||
|
||||
return await Task.FromResult(qrCodeData);
|
||||
}
|
||||
catch (SwissQrCodeNotFoundException)
|
||||
return (success: true, bill: bill, rawLines: rawLines, pageNumber: 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)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException("Failed to extract Swiss QR Code from PDF.", nameof(pdfBytes), ex);
|
||||
return (validResult.bill, validResult.rawLines!);
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
$"No valid Swiss QR Code found in {allImages.Count} images across pages: {string.Join(", ", pagesToScan)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a PDF page to a high-resolution bitmap for QR code detection
|
||||
/// Determines which pages to scan based on optional page numbers parameter.
|
||||
/// Default: Last page first, then all pages in reverse order.
|
||||
/// </summary>
|
||||
private static DXBitmap RenderPageToImage(PdfDocumentProcessor processor, int pageIndex)
|
||||
private static int[] DeterminePageNumbers(int totalPages, int[]? pageNumbers)
|
||||
{
|
||||
// Render page at high DPI for better QR code recognition
|
||||
var pageImage = processor.CreateDXBitmap(pageIndex + 1, QrCodeSearchDpi);
|
||||
return pageImage;
|
||||
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 ZXing library
|
||||
/// Decodes QR code from a DXBitmap image using SkiaSharp.QrCode.
|
||||
/// Converts DXBitmap to SKBitmap and attempts decoding.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static string? DecodeQrCodeFromImage(DXBitmap dxImage)
|
||||
{
|
||||
// Convert DXBitmap to System.Drawing.Bitmap via MemoryStream
|
||||
// Convert DXBitmap to SKBitmap via MemoryStream (PNG format)
|
||||
using var ms = new MemoryStream();
|
||||
dxImage.Save(ms, DXImageFormat.Png);
|
||||
ms.Position = 0;
|
||||
|
||||
using var gdiImage = Image.FromStream(ms);
|
||||
using var gdiBitmap = new Bitmap(gdiImage);
|
||||
// Decode using SkiaSharp.QrCode
|
||||
using var skBitmap = SKBitmap.Decode(ms);
|
||||
if (skBitmap == null)
|
||||
return null;
|
||||
|
||||
var reader = new ZXing.Windows.Compatibility.BarcodeReader
|
||||
{
|
||||
AutoRotate = true,
|
||||
Options = new ZXing.Common.DecodingOptions
|
||||
{
|
||||
PossibleFormats = [BarcodeFormat.QR_CODE],
|
||||
TryHarder = true,
|
||||
TryInverted = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = reader.Decode(gdiBitmap);
|
||||
return result?.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Swiss QR Bill content using Codecrete library
|
||||
/// </summary>
|
||||
private static SwissQrCodeData ParseSwissQrBillContent(string qrCodeText)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode Swiss QR Bill using Codecrete library
|
||||
var bill = QRBill.DecodeQrCodeText(qrCodeText);
|
||||
|
||||
// Determine reference type based on presence and format of reference
|
||||
string referenceType = DetermineReferenceType(bill.Reference);
|
||||
|
||||
// Map AlternativeSchemes to string array
|
||||
var alternativeParams = bill.AlternativeSchemes?
|
||||
.Select(s => $"{s.Name}: {s.Instruction}")
|
||||
.ToArray();
|
||||
|
||||
// Map to our domain value object
|
||||
return new SwissQrCodeData
|
||||
{
|
||||
QrType = "SPC", // Always SPC for Swiss Payment Code
|
||||
Version = bill.Version.ToString("D4"), // e.g., "0200" for version 2.0
|
||||
CodingType = "1", // Always UTF-8
|
||||
Iban = bill.Account ?? string.Empty,
|
||||
Creditor = MapAddress(bill.Creditor),
|
||||
UltimateCreditor = null, // Not exposed in Codecrete Bill model
|
||||
Amount = bill.Amount,
|
||||
Currency = bill.Currency ?? "CHF",
|
||||
UltimateDebtor = bill.Debtor != null ? MapAddress(bill.Debtor) : null,
|
||||
ReferenceType = referenceType,
|
||||
Reference = bill.Reference,
|
||||
UnstructuredMessage = bill.UnstructuredMessage,
|
||||
BillInformation = bill.BillInformation,
|
||||
AlternativeProcedureParameters = alternativeParams
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Failed to parse Swiss QR Code content. The QR code may not be a valid Swiss QR Bill.", "qrCodeText", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines reference type based on reference string format
|
||||
/// </summary>
|
||||
private static string DetermineReferenceType(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
return "NON";
|
||||
|
||||
// QRR (QR Reference): 27 digits
|
||||
if (reference.Length == 27 && reference.All(char.IsDigit))
|
||||
return "QRR";
|
||||
|
||||
// SCOR (Creditor Reference ISO 11649): starts with RF and has check digits
|
||||
if (reference.StartsWith("RF", StringComparison.OrdinalIgnoreCase) && reference.Length >= 5)
|
||||
return "SCOR";
|
||||
|
||||
return "NON";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Codecrete Address to our AddressData value object.
|
||||
///
|
||||
/// NOTE: AddressLine1 and AddressLine2 (Combined Address / K-Type) are deprecated
|
||||
/// as of Swiss Payment Standards 2025 (effective 21 Nov 2025).
|
||||
/// The Swiss QR Bill now mandates Structured Address (S-Type) format.
|
||||
/// These fields are retained for backward compatibility with legacy QR codes
|
||||
/// generated before the deprecation date.
|
||||
/// </summary>
|
||||
private static AddressData MapAddress(Codecrete.SwissQRBill.Generator.Address address)
|
||||
{
|
||||
return new AddressData
|
||||
{
|
||||
AddressType = address.Type == Codecrete.SwissQRBill.Generator.Address.AddressType.Structured ? "S" : "K",
|
||||
Name = address.Name ?? string.Empty,
|
||||
Street = address.Street,
|
||||
BuildingNumber = address.HouseNo,
|
||||
#pragma warning disable CS0618 // AddressLine1/AddressLine2 obsolete but required for backward compatibility
|
||||
AddressLine1 = address.AddressLine1,
|
||||
AddressLine2 = address.AddressLine2,
|
||||
#pragma warning restore CS0618
|
||||
PostalCode = address.PostalCode ?? string.Empty,
|
||||
City = address.Town ?? string.Empty,
|
||||
Country = address.CountryCode ?? string.Empty
|
||||
};
|
||||
// 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