使用 Mupdf 和 P/Invoke 在 C# 中渲染 PDF 文档
无需安装额外组件即可将 PDF 转换为位图。
Notice本文档出于历史原因保留。如需 P/Invoke 最新的 MuPDF 版本,请阅读本文档
引言
多年来,我一直在 .NET Framework 中寻找 PDF 渲染引擎。然而,这个世界上还没有免费的原生 .NET 引擎。最终,我被一个 GNU 许可、快速、轻量级的 PDF 渲染引擎——Mupdf 所吸引,并为其编写了自己的 C# 包装器。
本文将介绍编写 Mupdf 引擎的 P/Invoke C# 包装器的初步步骤,并将 PDF 页面渲染成图片文件。
背景和相关项目
在搜索 PDF 渲染引擎的过程中,我发现了两个提供良好 C# 接口的项目。这两个项目提供的库可以用来使用 C# 渲染 PDF 页面。
第一个项目最初发布在 CodeProject.com 上,标题为使用 Xpdf 和 muPDF 在 C# 中查看 PDF 文件,最终托管在 GoogleCode 上,名为pdfviewer-win32。它使用 C++/CLI 来桥接 C# 和 Mupdf。C++/CLI 方法的优点是允许开发者访问 Mupdf 库的几乎所有内部部分。缺点是开发者需要编写许多 C++/CLI 包装类。编写和调试这类类并非易事,而且许多 C# 开发者甚至不了解 C++。
第二个项目托管在 GoogleCode 上,名为mupdf-converter。它有 2 层:第一层引入了一些 C++ 类来封装库;第二层提供了一些 C 函数作为这些类公开的静态方法。最后,这些 C 函数以 DLL 的形式导出,供 P/Invoke 调用。简而言之,包装器的流程是:MuPDF -> C++ 包装器 -> C (导出为 DLL 函数) -> .NET P/Invoke。
既然存在像pdfviewer-win32或mupdf-converter这样的现有项目,为什么不直接使用它们呢?只要您不追求最新的开发进展或使用 Mupdf 库提供的所有功能,直接使用它们是可以的。在撰写本文时,pdfviewer-win32项目提供了很多功能,但已经一年多没有更新了。mupdf-converter库提供的功能非常有限——除了渲染 PDF 页面,您无法用它做其他事情——而且它仅在 .NET 4.0 或更高版本上运行。
我研究了mupdf-converter的源代码,发现它相当简单,其两层包装器实现也相当臃肿。实际上,没有必要使用 C++ 类来包装 Mupdf 的函数。由于 Mupdf 是用 C 编写的,因此可以从中创建一个 DLL,并通过 P/Invoke 直接调用其导出函数。也就是说,调用路径可以缩短为:MuPDF -> .NET P/Invoke。受这个库的启发,我开始制作自己的库,并因此写了这篇文章。
警告:本文档的以下部分假设您知道如何在 C# 中编写 P/Invoke 函数。如果您对此一无所知,可以从 MSDN 上学习。如果您不想费力,请使用pdfviewer-win32或mupdf-converter提供的现有库。
“制作”步骤
制作我们的 P/Invoke 专用的MupdfSharp库的步骤包括以下几点。
- 获取 Mupdf DLL 库。
- 学习 Mupdf 的基本概念和导出函数。
- 编写 P/Invoke 函数。
获取 Mupdf DLL
如果您曾经下载并编译过 Mupdf 的源代码,您可能会发现编译的输出是几个 EXE 文件,根本没有 DLL 库。因此,如果您不熟悉 MAKE 文件,可能需要花费几个小时来学习和修改 MAKE 文件才能获得 DLL 库。
幸运的是,这个世界上总有热心人。SumatraPDF 的开发者(一个利用 Mupdf 功能的轻量级 PDF 阅读器程序)是我们的救星。他们将代码文件发布在 Github 上,并且他们的项目编译后确实生成了一个供您重用的 DLL 库。因此,步骤可以很简单。
- 前往 SumatraPDF 的项目主页:https://github.com/sumatrapdfreader/sumatrapdf
- 下载源代码包(或使用 SVN 工具与他们的最新工作同步)。
- 打开 Visual C++(您可以在此处使用免费的 Express 版本)并加载项目。
- 选择“Release”作为构建配置并编译项目。
- 在 release 文件夹中找到 "libmupdf.dll" 文件。
- 您现在拥有了 Mupdf DLL。
SumatraPDF 的开发者是非常勤奋的程序员。他们紧密跟踪 Mupdf 的最新开发并频繁更新他们的代码。因此,您可以完全信任他们,并使用他们的库,而不是尝试从官方代码中自行编译 Mupdf DLL。
Notice今年春天,我发现 SumatraPDF 的开发者似乎停止了与 MuPDF 库的同步,早在几个月前。
正如他们所说,我检查了 MuPDF 的 API 并理解了他们的原因:“通常,这个问题需要大量的工作。Sumatra 对 mupdf 有相当多的更改,mupdf 变化很大,升级需要大量工作,但这(至少对我来说)不是优先事项。”
因此,我写了一篇文章,介绍如何编译我们自己的 MuPDF 版本。
2017-11-18
学习基本概念和函数
一旦您获得了libmupdf.dll,就可以开始研究 Mupdf 库提供的函数了。
该库是用 C 编写的。在 C 编程世界中,函数定义放在头文件中,文件扩展名为“.h”。
在 SumatraPDF 项目中,MuPDF 库的头文件位于mupdf\include\mupdf文件夹中。
从MuPDF 官方网站的文档部分,列出了五个头文件——“fitz.h”、“pdf.h”、“html.h”、“svg.h”和“xps.h”。我们只需要开始学习前两个。然后我们将跟随它们,找出所需的函数和结构。
以下行来自“fitz.h”文件。当我们在探索 MuPDF 库的功能时,我们将跟随 #include
指令,这表明引用的头文件也在库中使用,我们将逐一检查它们。
#ifndef MUDPF_FITZ_H
#define MUDPF_FITZ_H
#include "mupdf/fitz/version.h"
#include "mupdf/fitz/system.h"
#include "mupdf/fitz/context.h"
// the rest of the file is omitted here
当我们打开 fitz.h 或 pdf.h 文件中列出的文件时,例如 context.h,我们将看到一些函数和结构的定义。函数和结构的定义对于以后编写 P/Invoke 函数至关重要。
要了解 MuPDF 的工作机制,我们可以从 MuPDF 文档网站上列出的示例文件“source/tools/mudraw.c”开始。通过研究代码,我们可以了解 MuPDF 的工作原理。
保存文档内容的结构
fitz.h中的五个关键结构在代码中使用,如下所列。
fz_context
结构(又名context.h中定义的fz_context_s
):用于在处理 PDF 文件(或其他支持的文档格式)时保存信息。在处理之前必须准备好 context。fz_document
结构(又名document.h中定义的fz_document_s
):用于保存已打开的 PDF 文档。fz_page
结构(又名document.h中定义的fz_page_s
):用于处理 PDF 页面。一旦打开文档,就可以加载其页面并进行处理。fz_pixmap
结构(又名pixmap.h中定义的fz_pixmap_s
):代表页面的渲染视觉结果。如果您想渲染 PDF 文档,必须设置一个 pixmap 并在其上绘制。fz_device
结构(又名device.h中定义的fz_device_s
):代表渲染结果绘制的设备。通常,我们会从 pixmap 准备一个设备进行绘制。
您不必关心这些结构的内部布局。这意味着,当我们编写 P/Invoke 代码时,不必在 C# 代码中设置相应的 struct
。使用 IntPtr
来保存它们的引用即可。了解这一点可以大大简化我们之后在 P/Invoke 部分的工作。
操作结构的函数
通过了解上述关键结构,我们在示例文件中找到了以下有用的函数。
fz_new_context_imp
:创建fz_context
。fz_free_context
:释放fz_context
使用的资源。fz_open_document_with_stream
:从给定的fz_stream
创建fz_document
实例。fz_open_file_w
:使用 Unicode 编码的文件名打开fz_stream
。注意:如果您处理的文件名中没有非 ASCII 字符,可以使用fz_open_document
函数打开文档,而不是组合使用fz_open_file_w
和fz_open_document_with_stream
。fz_close_document
:关闭fz_document
。fz_close
:关闭fz_stream
。fz_count_pages
:获取文档中的页数。fz_load_page
:为给定的页码创建fz_page
实例。fz_free_page
:释放fz_page
使用的资源。fz_bound_page
:获取页面的尺寸。fz_new_pixmap
:创建fz_pixmap
实例以保存渲染的视觉结果。fz_clear_pixmap_with_value
:用颜色(通常是白色)填充fz_pixmap
。fz_new_draw_device
:创建fz_device
以绘制渲染结果。fz_lookup_device_colorspace
:获取fz_colorspace
结构,用于fz_run_page
函数。fz_run_page
:将页面渲染到指定的fz_device
。fz_free_device
:释放fz_device
使用的资源。fz_drop_pixmap
:释放fz_pixmap
使用的资源。fz_pixmap_samples
:获取渲染的fz_pixmap
的数据,用于渲染 Bitmap。
注意:如果您使用从 SumatraPDF 编译的 DLL
在 SumatraPDF 的最新版本中,为了缩小 DLL 库文件的大小,开发者决定在编译 libmupdf.dll 文件时剥离一些函数。因此,fitz.h文件中的一些函数没有被编译到 DLL文件中,我们必须改用pdf.h文件中等效的导出函数。
编写 P/Invoke 函数
注意:本章将展示 P/Invoke 函数是如何组成的。如果您不关心它们是如何来的,可以跳过本章。
一旦我们了解了要使用的函数,就可以开始在 C# 中编写 P/Invoke 函数了。首先,我们从 fitz\context.h 中的 fz_new_context_imp
函数开始,因为它创建了 fz_context
实例,并且后续的代码将依赖于该对象。
fz_context *fz_new_context_imp(fz_alloc_context *alloc, fz_locks_context *locks, unsigned int max_store, const char *version);
fz_context *
是函数的返回值,指向fz_context
结构的指针。在我们的 P/Invoke 函数中,将使用IntPtr
,因为我们不关心fz_context
的内部结构。fz_new_context_imp
自然是函数名。fz_alloc_context *
和fz_locks_context *
是函数前两个参数的类型。在使用fz_new_context
函数初始化fz_context
时,可以将IntPtr.Zero
传递给这两个参数,因此我们将在 P/Invoke 函数的相应位置使用IntPtr
。max_store
决定了引擎缓存资源的数量。头文件提供的推荐大小(256 << 20)等于 256MB。version
是传递给函数的字符串。
这是 fz_new_context_imp
函数的 P/Invoke 函数代码。
[DllImport (DLL, EntryPoint="fz_new_context_imp")]
static extern IntPtr NewContext (IntPtr alloc, IntPtr locks, uint max_store, string version);
由于调用者实际上不必了解 alloc
、locks
和 max_store
,我们可以添加一个重载函数,使方法看起来更简洁。
const uint FZ_STORE_DEFAULT = 256 << 20;
const string MuPDFVersion = "1.6";
public static IntPtr NewContext () {
return NewContext (IntPtr.Zero, IntPtr.Zero, FZ_STORE_DEFAULT, MuPDFVersion);
}
但是……你可能会问,“等等。你怎么知道我们可以将两个 IntPtr.Zero
和一个 FZ_STORE_DEFAULT
传递给 NewContext
函数?还有那个神秘字符串 '1.6'
是什么意思?”好问题。第一个问题的答案写在 fitz\context.h 中——“当您不需要预分配内存或设置锁时,可以使用 NULL”。在 P/Invoke 的世界里,NULL 表示前两个参数的 IntPtr.Zero
。FZ_STORE_DEFAULT
的值也在那里提到。
第二个问题的答案可以在 fitz\context.h 和 fitz\version.h 中找到。首先,我们在 context.h
中看到以下行:
#define fz_new_context(alloc, locks, max_store) fz_new_context_imp(alloc, locks, max_store, FZ_VERSION)
如果您研究 MuPDF 或 SumatraPDF 提供的示例代码,您会看到它们使用 fz_new_context
而不是 fz_new_context_imp
。但是,正如上面的代码行所示,fz_new_context
只是 C 中的一个宏定义,它没有从 DLL 导出到 C# 世界。当我们在 P/Invoke 库时,必须改用 fz_new_context_imp
。
接下来,我们可以在 version.h 中找到 FZ_VERSION 的定义。
警告:如果您传递的版本号与引擎版本不匹配,上述函数将返回 IntPtr.Zero
而不是 fz_context
实例。在编译自己的 libmupdf.dll 库时,至少应查看 fitz\version.h 文件,并检查 FZ_VERSION
定义是否与此处传递给函数的 version
参数匹配。
如果您有进一步的问题,请参阅 fitz 和 pdf 文件夹中的那些 *.h 文件。
我们对剩余的函数做同样的事情,并相应地编写 P/Invoke 函数。在此过程中,我们不可避免地会遇到另外三个新结构:fz_bbox
、fz_rectangle
和 fz_matrix
,它们由上面列出的函数引用。它们都非常简单。我们只需根据 fitz.h 中的定义,将它们定义为代码中的 struct
。最终,我们将得到类似下面的代码,可以供 P/Invoke 使用。
public struct BBox
{
public int Left, Top, Right, Bottom;
}
public struct Rectangle
{
public float Left, Top, Right, Bottom;
}
public struct Matrix
{
public float A, B, C, D, E, F;
}
class NativeMethods {
const uint FZ_STORE_DEFAULT = 256 << 20;
const string DLL = "libmupdf.dll";
const string MuPDFVersion = "1.6";
[DllImport (DLL, EntryPoint="fz_new_context")]
static extern IntPtr NewContext (IntPtr alloc, IntPtr locks, uint max_store, string version);
public static IntPtr NewContext () {
return NewContext (IntPtr.Zero, IntPtr.Zero, FZ_STORE_DEFAULT, MuPDFVersion);
}
[DllImport (DLL, EntryPoint = "fz_free_context")]
public static extern IntPtr FreeContext (IntPtr ctx);
[DllImport (DLL, EntryPoint = "fz_open_file_w", CharSet = CharSet.Unicode)]
public static extern IntPtr OpenFile (IntPtr ctx, string fileName);
[DllImport (DLL, EntryPoint = "pdf_open_document_with_stream")]
public static extern IntPtr OpenDocumentStream (IntPtr ctx, IntPtr stm);
[DllImport (DLL, EntryPoint = "fz_close")]
public static extern IntPtr CloseStream (IntPtr stm);
[DllImport (DLL, EntryPoint = "pdf_close_document")]
public static extern IntPtr CloseDocument (IntPtr doc);
[DllImport (DLL, EntryPoint = "pdf_count_pages")]
public static extern int CountPages (IntPtr doc);
[DllImport (DLL, EntryPoint = "pdf_bound_page")]
public static extern void BoundPage (IntPtr doc, IntPtr page, ref Rectangle bound);
[DllImport (DLL, EntryPoint = "fz_clear_pixmap_with_value")]
public static extern void ClearPixmap (IntPtr ctx, IntPtr pix, int byteValue);
[DllImport (DLL, EntryPoint = "fz_lookup_device_colorspace")]
public static extern IntPtr LookupDeviceColorSpace (IntPtr ctx, string colorspace);
[DllImport (DLL, EntryPoint = "fz_free_device")]
public static extern void FreeDevice (IntPtr dev);
[DllImport (DLL, EntryPoint = "pdf_free_page")]
public static extern void FreePage (IntPtr doc, IntPtr page);
[DllImport (DLL, EntryPoint = "pdf_load_page")]
public static extern IntPtr LoadPage (IntPtr doc, int pageNumber);
[DllImport (DLL, EntryPoint = "fz_new_draw_device")]
public static extern IntPtr NewDrawDevice (IntPtr ctx, IntPtr pix);
[DllImport (DLL, EntryPoint = "fz_new_pixmap")]
public static extern IntPtr NewPixmap (IntPtr ctx, IntPtr colorspace, int width, int height);
[DllImport (DLL, EntryPoint = "pdf_run_page")]
public static extern void RunPage (IntPtr doc, IntPtr page, IntPtr dev, ref Matrix transform, IntPtr cookie);
[DllImport (DLL, EntryPoint = "fz_drop_pixmap")]
public static extern void DropPixmap (IntPtr ctx, IntPtr pix);
[DllImport (DLL, EntryPoint = "fz_pixmap_samples")]
public static extern IntPtr GetSamples (IntPtr ctx, IntPtr pix);
}
使用代码 - 程序流程
本文档的目标是将 PDF 文档渲染成图片。过程非常直接。
- 加载文档。
- 遍历文档中的每一页。
- 将每一页渲染为 Bitmap 并保存到磁盘。
- 在操作过程中释放分配的资源。
代码流程骨架
代码如下。
static void Main (string[] args) {
IntPtr ctx = NativeMethods.NewContext (); // Creates the context
IntPtr stm = NativeMethods.OpenFile (ctx, "test.pdf"); // opens file test.pdf as a stream
IntPtr doc = NativeMethods.OpenDocumentStream (ctx, ".pdf", stm); // opens the document
int pn = NativeMethods.CountPages (doc); // gets the number of pages in the document
for (int i = 0; i < pn; i++) { // iterate through each pages
IntPtr p = NativeMethods.LoadPage (doc, i); // loads the page (first page number is 0)
Rectangle b = new Rectangle ();
b = NativeMethods.BoundPage (doc, p, ref b); // gets the page size
using (var bmp = RenderPage (ctx, doc, p, b)) { // renders the page and converts the result to Bitmap
bmp.Save ((i+1) + ".png"); // saves the bitmap to a file
}
NativeMethods.FreePage (doc, p); // releases the resources consumed by the page
}
NativeMethods.CloseDocument (doc); // releases the resources
NativeMethods.CloseStream (stm);
NativeMethods.FreeContext (ctx);
}
您可以看到上述代码的流程非常清晰。
页面渲染
我们尚未完成 RenderPage
函数的代码。我们将用以下几行代码完成它。
static Bitmap RenderPage (IntPtr context, IntPtr document, IntPtr page, Rectangle pageBound) {
Matrix ctm = new Matrix ();
IntPtr pix = IntPtr.Zero;
IntPtr dev = IntPtr.Zero;
int width = (int)(pageBound.Right - pageBound.Left); // gets the size of the page
int height = (int)(pageBound.Bottom - pageBound.Top);
ctm.A = ctm.D = 1; // sets the matrix as the identity matrix (1,0,0,1,0,0)
// creates a pixmap the same size as the width and height of the page
pix = NativeMethods.NewPixmap (context,
NativeMethods.LookupDeviceColorSpace (context, "DeviceRGB"), width, height);
// sets white color as the background color of the pixmap
NativeMethods.ClearPixmap (context, pix, 0xFF);
// creates a drawing device
dev = NativeMethods.NewDrawDevice (context, pix);
// draws the page on the device created from the pixmap
NativeMethods.RunPage (document, page, dev, ctm, IntPtr.Zero);
NativeMethods.FreeDevice (dev); // frees the resources consumed by the device
dev = IntPtr.Zero;
// creates a colorful bitmap of the same size of the pixmap
Bitmap bmp = new Bitmap (width, height, PixelFormat.Format24bppRgb);
var imageData = bmp.LockBits (new System.Drawing.Rectangle (0, 0,
width, height), ImageLockMode.ReadWrite, bmp.PixelFormat);
unsafe { // converts the pixmap data to Bitmap data
// gets the rendered data from the pixmap
byte* ptrSrc = (byte*)NativeMethods.GetSamples (context, pix);
byte* ptrDest = (byte*)imageData.Scan0;
for (int y = 0; y < height; y++) {
byte* pl = ptrDest;
byte* sl = ptrSrc;
for (int x = 0; x < width; x++) {
//Swap these here instead of in MuPDF because most pdf images will be rgb or cmyk.
//Since we are going through the pixels one by one
//anyway swap here to save a conversion from rgb to bgr.
pl[2] = sl[0]; //b-r
pl[1] = sl[1]; //g-g
pl[0] = sl[2]; //r-b
//sl[3] is the alpha channel, we will skip it here
pl += 3;
sl += 4;
}
ptrDest += imageData.Stride;
ptrSrc += width * 4;
}
}
// free bitmap in memory
bmp.UnlockBits (imageData);
NativeMethods.DropPixmap (context, pix);
return bmp;
}
好了,一切都*几乎*完成了。只需运行程序,您就可以看到 test.pdf 中的每个 PDF 页面都已转换为 PNG 文件。
关注点
在实际开发中,您需要注意几个问题。
首先需要注意的是异常:文档可能已损坏、被占用等。Mupdf 库在发生此类问题时会抛出异常。但是,由于我们正在进行 P/Invoke。我们所能做的就是捕获 AccessViolationException
,并重新抛出特定的、重新定义的异常,例如,在捕获加载文档时发生的异常时,可以重新抛出 PdfDocumentException。关键在于找出异常何时会抛出。您可以在 *.h 文件中找到信息。
其次要注意的是释放资源。您可以开发类来封装对象的创建和销毁。例如,您可以编写一个 MupdfPage
类来处理 fz_page
相关事宜。在 MupdfPage
的构造函数中,它创建 fz_page
实例。该类应实现 IDisposable
接口,并将 fz_free_page
的 P/Invoke 代码放在 Dispose
方法中以释放资源。
第三件需要考虑的事情是扩展功能。目前本文档介绍的包装器允许您将 PDF 页面转换为图片。您可能想做更多的事情,并从 Mupdf 中获得更多乐趣。有几种方法:
- 深入研究 pdf.h 和 pdf\*.h。该文件中还有许多本文档未涵盖的函数。例如,您可以找到打开密码保护文档的函数(搜索
pdf_needs_password
和pdf_authenticate_password
)。 - 从示例和现有的开源项目中学习。官方 Mupdf 网站提供了几个示例,并且上述 C 头文件在线提供。本文档中提到的三个项目也是您进一步学习的好参考。
- 再次注意:SumatraPDF 项目编译的 libmupdf.dll 文件不包含 fitz.h 文件中的所有“fz_”函数,但您可以在 pdf.h 中找到实际的实现。要找出哪些函数缺失,可以在 SumatraPDF 源代码的libmupdf.def文件中查找函数名。如果函数名列在那里,它可能可以在您的 P/Invoke 代码中使用;否则,请改用 pdf.h 中的函数。
第四个需要考虑的是在 64 位机器上运行。这是使用 P/Invoke 时最常见的问题之一。本文档下载中提供的 DLL,从 SumatraPDF 源代码编译而来,是 32 位的。它必须从 32 位的 .NET Framework 调用。在 64 位机器上,默认的 .NET Framework 是 64 位的,这在 P/Invoke 32 位 Mupdf DLL 时会失败。与其重新编译 Mupdf DLL 为 64 位 DLL,不如将 C# 项目的 CPU 平台从*Any CPU*更改为*x86*。因此,编译的 .NET 程序将被强制在 32 位 .NET Framework 上运行,并与 32 位 Mupdf DLL 正常工作。
第五个要考虑的可能是图像处理基础。在我们的示例中,我们使用默认分辨率渲染 PDF 页面。但是如果我们想让它更小(例如页面缩略图)或更大(方便阅读)怎么办?图像的“缩放”因子在于我们传递给 RunPage
函数的 Matrix
结构。如果我们修改其 A
和 D
数字,图像将分别水平和垂直缩放(修改其他值会导致图像旋转、倾斜或平移)。如果我们设置 A
或 D
为负值,渲染的图像将水平或垂直翻转。顺便说一句,别忘了调整渲染的 pixmap 和 Bitmap
的尺寸以容纳缩放后的图像。以下是供您参考的代码。您可能会将 zoomX
和 zoomY
编程为 RenderPage
函数的参数,并将原始函数中的第二个代码块替换为下面的其余代码行。
float zoomX = 1.0, zoomY = 1.0; int width = (int)(zoomX * (pageBound.Right - pageBound.Left)); // gets the size of the scaled page int height = (int)(zoomY * (pageBound.Bottom - pageBound.Top)); ctm.A = zoomX; ctm.D = zoomY; // sets the matrix as (zoomX,0,0,zoomY,0,0)
鸣谢
- pdfviewer-win32 项目让我接触了 XPDF 和 Mupdf。
- mupdf-converter 项目启发我不用 C++/CLI 编写这个包装器。
- SumatraPDF 项目在编译 DLL 文件方面提供了很大帮助。
历史
- 添加了关于 SumatraPDF 和 MuPDF 同步停止的说明。2017-9-15
- 更新以反映 MuPDF 网站的变化。2017-1-24
- 更新了 SumatraPDF 项目的位置。2014-12-30
- 再次修订,以反映 SumatraPDF 和 MuPDF 的最新变化。2014-10-9
- 修订以反映 SumatraPDF 和 MuPDF 的最新变化。2013-9-29
- 许可证已更改为 GPL3(与 MuPDF 兼容)。2013-3-28。