diff --git a/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj b/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj
index 19a62ac..65c42a7 100644
--- a/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj
+++ b/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj
@@ -6,12 +6,24 @@
enable
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -19,10 +31,4 @@
-
-
-
-
-
-
diff --git a/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs b/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs
index 73d26e0..1854ab3 100644
--- a/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs
+++ b/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs
@@ -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;
///
-/// 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
///
public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
{
- private const int QrCodeSearchDpi = 300; // High DPI for better QR code recognition
///
- [SupportedOSPlatform("windows")]
- public async Task 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)}.");
}
///
- /// 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.
///
- 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)];
}
///
- /// 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.
///
- [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;
- }
-
- ///
- /// Parses Swiss QR Bill content using Codecrete library
- ///
- 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);
- }
- }
-
- ///
- /// Determines reference type based on reference string format
- ///
- 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";
- }
-
- ///
- /// 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.
- ///
- 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;
}
}
diff --git a/DocumentOperator.Infrastructure/Services/StringExtensions.cs b/DocumentOperator.Infrastructure/Services/StringExtensions.cs
new file mode 100644
index 0000000..8e9ef50
--- /dev/null
+++ b/DocumentOperator.Infrastructure/Services/StringExtensions.cs
@@ -0,0 +1,16 @@
+using System.Xml.Serialization;
+
+namespace DocumentOperator.Infrastructure.Services;
+
+public static class StringExtensions
+{
+ public static T? DeserializeXml(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);
+ }
+}
\ No newline at end of file