📄 بازنویسی PDF Fallback با PdfiumViewer

جایگزینی PdfPigRenderer با PdfiumViewer برای تبدیل PDF به تصویر
👨‍💻 توسعه‌دهنده: هادی خزاعی اصل
🏢 شرکت: فن آوران ساحر علم
📅 تاریخ: یکشنبه ۱۳ مهر ۱۴۰۵
📦 پروژه: xAiApi

🎯 معرفی PdfiumViewer

PdfiumViewer یک کتابخانه قدرتمند و سریع برای کار با فایل‌های PDF در .NET است که بر پایه موتور PDFium گوگل (همان موتوری که Chrome استفاده می‌کند) ساخته شده است.

⚡ مزایای PdfiumViewer

  • سرعت بالا در رندر صفحات
  • پشتیبانی کامل از PDF های پیچیده
  • کیفیت رندر عالی
  • کنترل دقیق DPI و اندازه خروجی
  • پشتیبانی از فرمت‌های مختلف تصویر

⚠️ ملاحظات مهم

  • نیاز به Native DLL های PDFium
  • حجم فایل DLL ها حدود 10-20 مگابایت
  • نیاز به کپی DLL ها در output directory
  • Thread-safe نیست (نیاز به قفل)

📦 گام ۱: نصب پکیج‌های NuGet

# نصب پکیج اصلی PdfiumViewer
Install-Package PdfiumViewer

# نصب Native DLL های PDFium (برای پلتفرم x64)
Install-Package PdfiumViewer.Native

# یا برای هر دو پلتفرم x86 و x64
Install-Package PdfiumViewer.Native.x86
Install-Package PdfiumViewer.Native.x64
💡 نکته: پکیج PdfiumViewer.Native به صورت خودکار DLL های pdfium.dll را در پوشه x86 و x64 پروژه کپی می‌کند و در runtime بر اساس معماری سیستم، DLL مناسب را لود می‌کند.

🔧 گام ۲: بازنویسی تابع FallbackToImageExtractionAsync

MODIFY xAiApi/Providers/Extractors/XPdfFileContentExtractor.cs
using System;
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
using System.Threading;
using System.Threading.Tasks;
using PdfiumViewer;
using xAiApi.Models;

namespace xAiApi.Providers.Extractors
{
    public class XPdfFileContentExtractor : IXPdfFileContentExtractor
    {
        private const int MaxPagesForImageFallback = 10;
        private const int MinTextLengthThreshold = 50;
        
        /// <summary>
        /// DPI پیش‌فرض برای رندر تصاویر (72 = استاندارد، 150 = کیفیت بالا)
        /// </summary>
        private const int DefaultDpi = 150;

        // ... (سایر متدها)

        /// <summary>
        /// Fallback: تبدیل صفحات PDF به تصویر با استفاده از PdfiumViewer ...
        /// </summary>
        private async Task<XFileExtractionResult> FallbackToImageExtractionAsync(
            Stream pdfStream,
            string fileName,
            string mimeType,
            CancellationToken cancellationToken
        )
        {
            var result = new XFileExtractionResult
            {
                FileName = fileName,
                MimeType = mimeType,
                UsedImageFallback = true
            };

            // کپی Stream به MemoryStream برای PdfiumViewer
            // (PdfiumViewer به Stream قابل Seek نیاز دارد)
            using var memoryStream = new MemoryStream();
            await pdfStream.CopyToAsync(memoryStream, cancellationToken);
            memoryStream.Position = 0;

            await Task.Run(() =>
            {
                try
                {
                    // بارگذاری PDF با PdfiumViewer
                    using var pdfDocument = PdfDocument.Load(memoryStream);
                    
                    // محاسبه تعداد صفحات برای پردازش
                    var pageCount = Math.Min(
                        pdfDocument.PageCount,
                        MaxPagesForImageFallback
                    );

                    // حلقه روی صفحات و تبدیل به تصویر
                    for (int pageIndex = 0; pageIndex < pageCount; pageIndex++)
                    {
                        // بررسی CancellationToken
                        cancellationToken.ThrowIfCancellationRequested();

                        // رندر صفحه به Bitmap
                        // متد RenderPage پارامترهای زیر را می‌پذیرد:
                        // - pageIndex: شماره صفحه
                        // - width: عرض تصویر خروجی (0 = اندازه اصلی)
                        // - height: ارتفاع تصویر خروجی (0 = اندازه اصلی)
                        // - dpi: کیفیت رندر
                        using var bitmap = pdfDocument.RenderPage(
                            pageIndex: pageIndex,
                            width: 0,      // 0 = استفاده از اندازه اصلی
                            height: 0,     // 0 = استفاده از اندازه اصلی
                            dpiX: DefaultDpi,
                            dpiY: DefaultDpi
                        );

                        if (bitmap != null)
                        {
                            // تبدیل Bitmap به PNG
                            using var imageStream = new MemoryStream();
                            bitmap.Save(imageStream, ImageFormat.Png);
                            var imageBytes = imageStream.ToArray();

                            // افزودن به لیست تصاویر
                            result.Images.Add(new XExtractedImage
                            {
                                Bytes = imageBytes,
                                PageNumber = pageIndex + 1,
                                MimeType = "image/png",
                                Description = $"Page {pageIndex + 1} of {fileName}"
                            });
                        }
                    }
                }
                catch (OperationCanceledException)
                {
                    // عملیات لغو شد
                    throw;
                }
                catch (Exception ex)
                {
                    // خطا در پردازش PDF
                    result.ErrorMessage = $"Failed to render PDF pages to images: {ex.Message}";
                }
            }, cancellationToken);

            return result;
        }

        // ... (سایر متدها)
    }
}

🎨 گام ۳: نسخه پیشرفته با کنترل دقیق DPI

اگر نیاز به کنترل دقیق‌تر روی کیفیت و اندازه تصاویر دارید، از این نسخه استفاده کنید:

ADVANCED نسخه پیشرفته با کنترل DPI
/// <summary>
/// Fallback پیشرفته با کنترل دقیق DPI و کیفیت ...
/// </summary>
private async Task<XFileExtractionResult> FallbackToImageExtractionAdvancedAsync(
    Stream pdfStream,
    string fileName,
    string mimeType,
    int targetDpi = 150,
    ImageFormat outputFormat = null,
    CancellationToken cancellationToken = default
)
{
    outputFormat ??= ImageFormat.Png;
    
    var result = new XFileExtractionResult
    {
        FileName = fileName,
        MimeType = mimeType,
        UsedImageFallback = true
    };

    using var memoryStream = new MemoryStream();
    await pdfStream.CopyToAsync(memoryStream, cancellationToken);
    memoryStream.Position = 0;

    await Task.Run(() =>
    {
        using var pdfDocument = PdfDocument.Load(memoryStream);
        var pageCount = Math.Min(pdfDocument.PageCount, MaxPagesForImageFallback);

        for (int pageIndex = 0; pageIndex < pageCount; pageIndex++)
        {
            cancellationToken.ThrowIfCancellationRequested();

            // دریافت اندازه اصلی صفحه
            var pageSize = pdfDocument.PageSizes[pageIndex];
            
            // محاسبه اندازه تصویر بر اساس DPI
            var width = (int)(pageSize.Width * targetDpi / 72.0);
            var height = (int)(pageSize.Height * targetDpi / 72.0);

            // رندر با اندازه مشخص
            using var bitmap = pdfDocument.RenderPage(
                pageIndex: pageIndex,
                width: width,
                height: height,
                dpiX: targetDpi,
                dpiY: targetDpi
            );

            if (bitmap != null)
            {
                using var imageStream = new MemoryStream();
                bitmap.Save(imageStream, outputFormat);
                var imageBytes = imageStream.ToArray();

                result.Images.Add(new XExtractedImage
                {
                    Bytes = imageBytes,
                    PageNumber = pageIndex + 1,
                    MimeType = outputFormat == ImageFormat.Jpeg ? "image/jpeg" : "image/png",
                    Description = $"Page {pageIndex + 1} of {fileName} ({width}x{height}px @ {targetDpi}dpi)"
                });
            }
        }
    }, cancellationToken);

    return result;
}

⚙️ گام ۴: پیکربندی DPI در appsettings.json

CONFIG appsettings.json
{
  "AiApiConfiguration": {
    "FileExtraction": {
      "PdfImageFallback": {
        "Enabled": true,
        "MaxPages": 10,
        "Dpi": 150,
        "OutputFormat": "png",
        "MinTextLengthThreshold": 50
      }
    }
  }
}
CLASS xAiApi/Configurations/XPdfImageFallbackConfiguration.cs
namespace xAiApi.Configurations
{
    public class XPdfImageFallbackConfiguration
    {
        public bool Enabled { get; set; } = true;
        public int MaxPages { get; set; } = 10;
        public int Dpi { get; set; } = 150;
        public string OutputFormat { get; set; } = "png";
        public int MinTextLengthThreshold { get; set; } = 50;
    }
}

🔗 گام ۵: استفاده از پیکربندی در Extractor

MODIFY XPdfFileContentExtractor.cs
public class XPdfFileContentExtractor : IXPdfFileContentExtractor
{
    private readonly XPdfImageFallbackConfiguration _config;

    public XPdfFileContentExtractor(XPdfImageFallbackConfiguration config)
    {
        _config = config ?? new XPdfImageFallbackConfiguration();
    }

    private async Task<XFileExtractionResult> FallbackToImageExtractionAsync(
        Stream pdfStream,
        string fileName,
        string mimeType,
        CancellationToken cancellationToken
    )
    {
        var result = new XFileExtractionResult
        {
            FileName = fileName,
            MimeType = mimeType,
            UsedImageFallback = true
        };

        using var memoryStream = new MemoryStream();
        await pdfStream.CopyToAsync(memoryStream, cancellationToken);
        memoryStream.Position = 0;

        await Task.Run(() =>
        {
            using var pdfDocument = PdfDocument.Load(memoryStream);
            var pageCount = Math.Min(pdfDocument.PageCount, _config.MaxPages);

            for (int pageIndex = 0; pageIndex < pageCount; pageIndex++)
            {
                cancellationToken.ThrowIfCancellationRequested();

                using var bitmap = pdfDocument.RenderPage(
                    pageIndex: pageIndex,
                    width: 0,
                    height: 0,
                    dpiX: _config.Dpi,
                    dpiY: _config.Dpi
                );

                if (bitmap != null)
                {
                    using var imageStream = new MemoryStream();
                    
                    // انتخاب فرمت خروجی بر اساس پیکربندی
                    var format = _config.OutputFormat?.ToLowerInvariant() switch
                    {
                        "jpeg" or "jpg" => ImageFormat.Jpeg,
                        "bmp" => ImageFormat.Bmp,
                        _ => ImageFormat.Png
                    };
                    
                    bitmap.Save(imageStream, format);
                    var imageBytes = imageStream.ToArray();

                    result.Images.Add(new XExtractedImage
                    {
                        Bytes = imageBytes,
                        PageNumber = pageIndex + 1,
                        MimeType = format == ImageFormat.Jpeg ? "image/jpeg" : "image/png",
                        Description = $"Page {pageIndex + 1} of {fileName}"
                    });
                }
            }
        }, cancellationToken);

        return result;
    }
}

🔌 گام ۶: ثبت در Dependency Injection

MODIFY xAiApi/Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // ... (سایر ثبت‌ها)

    // ✅ ثبت پیکربندی PDF Image Fallback
    services.Configure<XPdfImageFallbackConfiguration>(
        Configuration.GetSection("AiApiConfiguration:FileExtraction:PdfImageFallback")
    );

    // ✅ ثبت PDF Extractor با پیکربندی
    services.AddSingleton<IXFileContentExtractor>(sp =>
    {
        var config = sp.GetRequiredService<IOptions<XPdfImageFallbackConfiguration>>().Value;
        return new XPdfFileContentExtractor(config);
    });

    // ... (بقیه کدها)
}

🐛 گام ۷: عیب‌یابی و نکات مهم

مشکل علت راه‌حل
DllNotFoundException pdfium.dll یافت نشد نصب PdfiumViewer.Native و rebuild پروژه
BadImageFormatException عدم تطابق معماری (x86/x64) استفاده از پکیج Native مناسب یا تنظیم Platform Target
خطا در PdfDocument.Load Stream غیرقابل Seek کپی به MemoryStream قبل از Load
کیفیت پایین تصاویر DPI خیلی کم افزایش DPI به 200 یا 300
OutOfMemoryException تعداد صفحات زیاد یا DPI بالا کاهش MaxPages یا Dpi
⚠️ Thread Safety:

PdfiumViewer Thread-safe نیست. اگر چندین درخواست همزمان PDF را پردازش می‌کنند، باید از lock یا SemaphoreSlim استفاده کنید:

private static readonly SemaphoreSlim _pdfLock = new SemaphoreSlim(2, 2); // حداکثر 2 همزمان

await _pdfLock.WaitAsync(cancellationToken);
try
{
    // پردازش PDF
}
finally
{
    _pdfLock.Release();
}

📊 گام ۸: مقایسه PdfPigRenderer و PdfiumViewer

ویژگی PdfPigRenderer PdfiumViewer
سرعت رندر ⭐⭐⭐ متوسط ⭐⭐⭐⭐⭐ بسیار سریع
کیفیت خروجی ⭐⭐⭐ خوب ⭐⭐⭐⭐⭐ عالی
حجم DLL کوچک (فقط .NET) بزرگ (10-20 MB Native)
پشتیبانی از PDF های پیچیده ⭐⭐⭐ محدود ⭐⭐⭐⭐⭐ کامل
کنترل DPI ⭐⭐ محدود ⭐⭐⭐⭐⭐ دقیق
Thread Safety ✅ Thread-safe ❌ نیاز به قفل
نیاز به Native DLL ❌ خیر ✅ بله
✅ نتیجه‌گیری:

PdfiumViewer انتخاب بهتری برای سناریوهای Production است، به ویژه اگر:

  • کیفیت رندر برایتان مهم است
  • با PDF های پیچیده (فونت‌های خاص، تصاویر، فرم‌ها) کار می‌کنید
  • سرعت پردازش اولویت دارد
  • حجم DLL ها مسئله‌ای نیست

📋 گام ۹: خلاصه تغییرات

فایل تغییر
XPdfFileContentExtractor.cs بازنویسی FallbackToImageExtractionAsync با PdfiumViewer
XPdfImageFallbackConfiguration.cs کلاس جدید برای پیکربندی
appsettings.json افزودن بخش PdfImageFallback
Startup.cs ثبت پیکربندی و Extractor در DI
NuGet Packages نصب PdfiumViewer و PdfiumViewer.Native
💡 نکات کلیدی:
  • ✅ PdfiumViewer سریع‌تر و باکیفیت‌تر از PdfPigRenderer است
  • ✅ کنترل دقیق DPI و اندازه خروجی
  • ⚠️ نیاز به Native DLL ها (حدود 10-20 MB)
  • ⚠️ Thread-safe نیست و نیاز به قفل دارد
  • ✅ پیکربندی‌پذیر از طریق appsettings.json