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