using Codecrete.SwissQRBill.Generator;
using DevExpress.Drawing;
using DevExpress.Pdf;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using SkiaSharp;
using SkiaSharp.QrCode;
using System.Collections.Concurrent;
namespace DocumentOperator.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;
}
}