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:
2026-07-16 15:47:33 +02:00
parent 889144f144
commit 88bde13422
3 changed files with 146 additions and 156 deletions

View File

@@ -6,12 +6,24 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Compile Remove="DependencyInjection\**" />
<Compile Remove="Services\DocumentValidation\**" />
<Compile Remove="Services\FileStorage\**" />
<EmbeddedResource Remove="DependencyInjection\**" />
<EmbeddedResource Remove="Services\DocumentValidation\**" />
<EmbeddedResource Remove="Services\FileStorage\**" />
<None Remove="DependencyInjection\**" />
<None Remove="Services\DocumentValidation\**" />
<None Remove="Services\FileStorage\**" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Codecrete.SwissQRBill.Generator" Version="3.4.0" /> <PackageReference Include="Codecrete.SwissQRBill.Generator" Version="3.4.0" />
<PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" /> <PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="SkiaSharp.QrCode" Version="1.0.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.9" /> <PackageReference Include="System.Drawing.Common" Version="10.0.9" />
<PackageReference Include="ZXing.Net.Bindings.Windows.Compatibility" Version="0.16.14" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -19,10 +31,4 @@
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" /> <ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="DependencyInjection\" />
<Folder Include="Services\FileStorage\" />
<Folder Include="Services\DocumentValidation\" />
</ItemGroup>
</Project> </Project>

View File

@@ -2,194 +2,162 @@ using Codecrete.SwissQRBill.Generator;
using DevExpress.Drawing; using DevExpress.Drawing;
using DevExpress.Pdf; using DevExpress.Pdf;
using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Exceptions; using DocumentOperator.Domain.Exceptions;
using DocumentOperator.Domain.ValueObjects; using SkiaSharp;
using System.Drawing; using SkiaSharp.QrCode;
using System.Collections.Concurrent;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using ZXing;
namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing; namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing;
/// <summary> /// <summary>
/// Swiss QR Code processor using DevExpress PDF API for PDF access /// Swiss QR Code processor using DevExpress PDF API for image extraction,
/// and Codecrete.SwissQRBill.Generator for QR Code parsing. /// 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> /// </summary>
public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
{ {
private const int QrCodeSearchDpi = 300; // High DPI for better QR code recognition
/// <inheritdoc /> /// <inheritdoc />
[SupportedOSPlatform("windows")] public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
public async Task<SwissQrCodeData> ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) 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(); cancellationToken.ThrowIfCancellationRequested();
pdfDocument.LoadDocument(new MemoryStream(pdfBytes));
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 if (allImages.IsEmpty)
int lastPageIndex = pdfDocument.Document.Pages.Count - 1; throw new NotFoundException($"No images found in pages: {string.Join(", ", pagesToScan)}");
// Convert last page to image for QR code detection // Parallel scan all images
using var pageImage = RenderPageToImage(pdfDocument, lastPageIndex); var qrCodeTasks = allImages.Select(imageData =>
Task.Run(() =>
// Detect and decode QR code
string? qrCodeContent = DecodeQrCodeFromImage(pageImage);
if (string.IsNullOrEmpty(qrCodeContent))
{ {
throw new SwissQrCodeNotFoundException( using (imageData.image)
$"No QR Code found on the last page (page {lastPageIndex + 1}) of the PDF document."); {
} 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 // Split raw text into lines (handle both \r\n and \n)
SwissQrCodeData qrCodeData = ParseSwissQrBillContent(qrCodeContent); // 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); return (success: true, bill: bill, rawLines: rawLines, pageNumber: imageData.pageNumber);
} }
catch (SwissQrCodeNotFoundException) 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; return (validResult.bill, validResult.rawLines!);
}
catch (Exception ex)
{
throw new ArgumentException("Failed to extract Swiss QR Code from PDF.", nameof(pdfBytes), ex);
} }
throw new NotFoundException(
$"No valid Swiss QR Code found in {allImages.Count} images across pages: {string.Join(", ", pagesToScan)}.");
} }
/// <summary> /// <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> /// </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 if (pageNumbers != null && pageNumbers.Length > 0)
var pageImage = processor.CreateDXBitmap(pageIndex + 1, QrCodeSearchDpi); {
return pageImage; // 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> /// <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> /// </summary>
[SupportedOSPlatform("windows")]
private static string? DecodeQrCodeFromImage(DXBitmap dxImage) 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(); using var ms = new MemoryStream();
dxImage.Save(ms, DXImageFormat.Png); dxImage.Save(ms, DXImageFormat.Png);
ms.Position = 0; ms.Position = 0;
using var gdiImage = Image.FromStream(ms); // Decode using SkiaSharp.QrCode
using var gdiBitmap = new Bitmap(gdiImage); using var skBitmap = SKBitmap.Decode(ms);
if (skBitmap == null)
return null;
var reader = new ZXing.Windows.Compatibility.BarcodeReader // TryDecode returns true if QR code found and decoded successfully
{ bool success = QRCodeDecoder.TryDecode(skBitmap, out var text, out _);
AutoRotate = true, return success ? text : null;
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
};
} }
} }

View File

@@ -0,0 +1,16 @@
using System.Xml.Serialization;
namespace DocumentOperator.Infrastructure.Services;
public static class StringExtensions
{
public static T? DeserializeXml<T>(this string xmlText)
{
if (string.IsNullOrWhiteSpace(xmlText))
return default;
var serializer = new XmlSerializer(typeof(T));
using var reader = new StringReader(xmlText);
return (T?)serializer.Deserialize(reader);
}
}