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; /// /// 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 { /// 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)}."); } /// /// Determines which pages to scan based on optional page numbers parameter. /// Default: Last page first, then all pages in reverse order. /// 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)]; } /// /// Decodes QR code from a DXBitmap image using SkiaSharp.QrCode. /// Converts DXBitmap to SKBitmap and attempts decoding. /// 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; } }