本項目對PdfiumViewer庫進行了改寫,對其pdf解析部分的核心功能進行了分離和精簡,使其支持任意程序調用生成渲染後圖片,項目代碼已全部開源 (https://github.com/LdotJdot/LumPdfiumViewerSlim)。
同時我們還給出了一個用Avalonia簡單實現了渲染頁面的UI,改造後的庫是完全支持如Winform還有後端調用的。

C#打開pdf文件 - Clotho_Image

下文將分享一下這個過程。

一、基於PDFium庫

需求來了,就得想辦法實現誒。要預覽PDF文件,需要先完成對PDF的解析,即將PDF中各類數據提取出來,然後再實現對解析數據的繪製渲染到軟件層面。在C#中關於PDF解析庫有很多,我們優先考慮的主要為用C#對PDFium封裝庫。PDFium是基於由google維護的項目 (https://github.com/chromium/pdfium) (Apache-2.0 license),維護很頻繁(本文發出前2小時Pdfium倉庫還在更新),此外他的特點是採用動態鏈接庫,C++編譯體積很小5mb,兼容性較好,接口規範而且功能強大。

二、C#現有Pdf預覽庫PdfiumViewer的不便

為了不造輪子,我們找到了 PdfiumViewer (https://github.com/bezzad/PdfiumViewer,Apache-2.0 license), 是一個基於Pdfium封裝後實現了預覽效果的C#庫。
儘管PDFium預覽效果很好,但最大的問題是,PdfiumViewer中對渲染後頁面的創建深度依賴了WPF框架(true),而且是與解析渲染方法組合在一起的。由於很多內部對象大量引用了WPF元素。因此在後端調用時或用於其他如Winform、Avalonia框架時非常不方便。如果想要單純集成至Winform或後端服務器調用那麼還必須帶着WPF框架過去,非常不方便,只能自定義對PdfiumViewer進行改造了。

三、Pdf預覽原理檢視及修改

Pdf的預覽主要是由解析+渲染兩部分實現。PdfiumViewer中,解析和底層渲染都是基於Pdfium庫的封裝調用實現的。如:,

[DllImport("pdfium.dll")]
public static extern IntPtr FPDFBitmap_CreateEx(int width, int height, int format, IntPtr first_scan, int stride);

[DllImport("pdfium.dll")]
public static extern void FPDFBitmap_FillRect(IntPtr bitmapHandle, int left, int top, int width, int height, uint color);

[DllImport("pdfium.dll")]
public static extern void FPDF_RenderPageBitmap(IntPtr bitmapHandle, IntPtr page, int start_x, int start_y, int size_x, int size_y, int rotate, FPDF flags);

在PdfiumView封裝的方法中,將額外考慮:

  • 狀態檢查和DPI校正
  • 位圖對象管理及內存鎖定
  • 原生PDF渲染 (Pdfium中渲染準備)
  • 背景設置和頁面設置
  • 實際PDF渲染 (渲染到位圖)

PdfiumView封裝後的方法為:

public Image Render(int page, int width, int height, float dpiX, float dpiY, PdfRotation rotate, PdfRenderFlags flags)

基於這個方法僅需要傳入頁碼(從0開始)、寬度、高度、dpi、旋轉等參數,就可以得到PDF文件中指定頁的渲染後圖片了。

四、精簡處理及Avalonia頁面實現

為了更加通用,我們對PdfiumViewer中依賴WPF的代碼進行了刪減,主要包括書籤相關的對象,滾動面板及動態渲染等,最後留下的只有指定PDF頁渲染圖片的功能。在大部分場景中已經夠用了,而且是支持AOT發佈的,也可以作為一個獨立進程工具供其他程序調用。
為了測試效果,我們選擇在Avalonia中創建一個可滾動的頁面,創建一個虛擬模式的ItemsControl,基於Image對象用於顯示最終渲染的圖片頁IImage:

<ScrollViewer>
	<ItemsControl  x:Name="pageList"
     IsTabStop="False"
		Focusable="False"
		IsHitTestVisible="True"

		ItemsSource="{Binding RenderedPages.DisplayedData, Mode=OneWay}" Background="Transparent">
		<ItemsControl.ItemsPanel>
			<ItemsPanelTemplate>
				<VirtualizingStackPanel/>
			</ItemsPanelTemplate>
		</ItemsControl.ItemsPanel>
		
		<ItemsControl.ItemTemplate>
			
			<DataTemplate>
				<Image 
					Source="{Binding Image}"
					   Stretch="Uniform"/>
			</DataTemplate>
		</ItemsControl.ItemTemplate>
	</ItemsControl >
</ScrollViewer>

綁定的ViewModel很簡單,主要是為了頁面的延遲渲染,即滾動到哪一頁,就調用上述組件的方法,實時渲染當前頁圖片。

public interface IPageRender
    {
        public int page { get; }

        public IImage Image {get;}
    }

    public class PageRender
    {
        PdfDocument _pdfDocument;
        public PageRender(PdfDocument _pdfDocument, int pageNumber)
        {
            this._pdfDocument= _pdfDocument;
            this.page = pageNumber;
        }

        public IImage Image=>GetImage();

       IImage GetImage()
        {
            try
            {
                using var image = _pdfDocument.Render(page, 800, 1200, 192, 192);
                            
                return ConvertToAvaloniaBitmap(image);
            }
            catch
            {
                return null;
            }
        }


        private Avalonia.Media.Imaging.Bitmap ConvertToAvaloniaBitmap(System.Drawing.Image image)
        {
            using (var memoryStream = new MemoryStream())
            {                
                image.Save(memoryStream,ImageFormat.Png);
                memoryStream.Position = 0;
                return new Avalonia.Media.Imaging.Bitmap(memoryStream);
            }
        }

        public int page { get; }
    }

    public class PageViewModel: ReactiveObject,IDisposable
    {
        private PdfDocument _pdfDocument;

        IEnumerable<PageRender> _displayedData=[];

        public IEnumerable<PageRender> DisplayedData
        {
            get => _displayedData;
            private set => this.RaiseAndSetIfChanged(ref _displayedData, value);
        }

        public void Load(string path)
        {
            _pdfDocument?.Dispose();
            _pdfDocument = PdfDocument.Load(path);
            Initialize(_pdfDocument);
        }

        private void Initialize(PdfDocument pdfDocument)
        {
            _pdfDocument = pdfDocument;

            // 頁面範圍
            DisplayedData = Enumerable.Range(0, pdfDocument.PageCount).Select(o=>new PageRender(_pdfDocument, o));

        }

        public void Dispose()
        {            
            _pdfDocument?.Dispose();
        }
    }

實現效果如下:

C#打開pdf文件 - Clotho_#c#_02

五、最後

儘管AOT啓動速度快,但我們還是更偏向與單文件的壓縮發佈,因為AOT發佈後,加上非託管庫,體積會比較大。而單文件壓縮將非託管後一起打包後大小僅不到26Mb,一個小巧的PDF閲讀器就誕生了,是不是很棒。 如果你對本文建議或想法,歡迎隨時交流。以後會和大家分享更多有趣內容,一起學習交流進步。項目代碼已全部開源 (https://github.com/LdotJdot/LumPdfiumViewerSlim),歡迎給個星星。