用C#来解析PDF文件
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
⽤C#来解析PDF⽂件
1. 介绍
这个项⽬让你可以去读取并解析⼀个PDF⽂件,并将其内部结构展⽰出来. PDF⽂件的格式标准⽂档可以从Adobe那⼉获取到.这个项⽬基于“PDF指南,第六版,Adobe便携⽂档格式1.7 2006年11⽉”. 它是⼀个恐怕有1310页的⼤部头. 本⽂提供了对这份⽂档的简洁概述. 与此相关的项⽬定义了⽤来读取和解析PDF⽂件的C#类. 为了测试这些类,附带的测试程序PdfFileAnalyzer 让你可以去读取⼀个PDF⽂件,分析它并展⽰和保存结果. 程序将PDF⽂件分割成单独每页的描述,字体,图⽚和其它对象.有两种类型的PDF⽂件不受此程序的⽀持: 加密⽂件和多代⽂件.
这个程序的1.1版本允许世界各地使⽤点符号作为⼩数分隔符的程序员来编译和运⾏程序.
1.2版本则修复了⼀个有关使⽤跨多个引⽤流来读取PDF⽂档的问题. 1.2之前的版本对此场景只会以⼀个对象数字重复的错误⽽终⽌运⾏.
2. 概要
PDF格式的⽂件,借助Adobe Acrobat软件,可以在各种屏幕上显⽰查看,使⽤各种打印机打印。
但是,如果使⽤⼆进制⽂件编辑器打开PDF⽂件,你会发现⽂件的⼤部分是不可读的,有⼩部分是可读的,如下:
1 0 obj
<</Lang(en-CA)/MarkInfo<</Marked true>>/Pages 2 0 R
/StructTreeRoot 10 0 R/Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[4 0 R]/Type/Pages>>
endobj
4 0 obj
<</Contents 5 0 R/Group <</CS/DeviceRGB /S/Transparency /Type/Group>>
/MediaBox[0 0 612 792] /Parent 2 0 R
/Resources <</Font <</F1 6 0 R /F2 8 0 R>>
/ProcSet[/PDF/Text/ImageB/ImageC/ImageI]>>
/StructParents 0/Tabs/S/Type/Page>>
endobj
5 0 obj
<</Filter/FlateDecode/Length 2319>>
stream
. . .
endstream
endobj
看上去,该⽂件是由嵌套在“n 0 OBJ ”和“ endobj ”关键词之间的对象组成的,术语PDF也就是间接对象的意思。
“obj”前⾯的数字是对象编号和第⼏代对象标识,双尖括号中的内容表⽰数据字典对象,中括号中的内容表⽰数组对象,以斜杠/ 开始的内容表⽰参数名称 (例如: /Pages)。
上例中的第⼀项 “1 0 obj” 表⽰⽂档的⽬录或者⽂档的根对象。
⽂档⽬录的字典对象 “/Pages 2 0 R”,指向定义页码树对象的引⽤。
按照这样推算,编号为2的对象包含指向 “/Kids[4 0 R]”的页⾯的引⽤,是⼀个页⾯⽂档。
编号为4的对象是唯⼀的⼀个页⾯定义,页⾯⼤⼩为612*792点,换句话说,也就是8.5” * 11” (1” 代表72 点)点。
该页⾯使⽤了两种字体F1和F2,这两种字体分别在编号为6和8的对象中定义。
该页⾯的内容在编号为5的对象中描述,该对象中包含页⾯绘图的流信息,⽰例中的 “. . .”代表这部分流信息。
如果使⽤⼆进制⽂件编辑器打开PDF⽂件,会发现这部分流信息看起来是⼀长串不可读的随机数,原因是那是压缩数据。
流数据采⽤Zlib⽅法压缩,压缩⽅式由字典对象“/Filter /FlateDecode”描述,被压缩流的⼤⼩为2319字节。
解压这部分流信息,前⾯⼏⾏内容如下所⽰:
q
37.08 56.424 537.84 679.18 re
W* n
/P <</MCID 0>> BDC 0.753 g
36.6 465.43 537.96 24.84 re
f*
EMC /P <</MCID 1/Lang (x-none)>> BDC BT
/F1 18 Tf
1 0 0 1 39.6 718.8 Tm
0 g
0 G
[(GRA)29(NOTECH LI)-3(MIT)-4(ED)] TJ
ET
这是页⾯描述语⾔的⼀个⼩例⼦。
⽰例中, “re” 代表矩形,“re” 前⾯的4个数字代表矩形的位置和⼤⼩,依次为:起点横坐标、起点纵坐标、宽度、⾼度。
这个简单的例⼦演⽰了PDF⽂件内部实现的总体思路。
从页⾯层次结构的根对象开始,每⼀页都定义了诸如字体、图⽚、内容流的资源,内容流由操作符和绘制页⾯所需要的参数构成。
PDF⽂件分析器会产⽣⼀个对象汇总⽂件,该⽂件包含⾮流对象的其他所有对象。
每个数据流会被解码并保存为⼀个单独的⽂件,页⾯描述流保存为⽂本格式的⽂件,图⽚流保存为.jpg 或.bmp格式的⽂件,字体流保存为.ttf格式的⽂件,其他⼆进制流保存为.bin 格式的⽂件,⽂本流保存为.txt格式的⽂件。
通过另⼀个解析过程,晦涩难懂的页⾯描述会被转换为伪C#代码,如上例中的页⾯描述被转为:
SaveGraphicsState(); // q
Rectangle(37.08, 56.424, 537.84, 679.18); // re
ClippingPathEvenOddRule(); // W*
NoPaint(); // n
BeginMarkedContentPropList("/P", "<</MCID 0>>"); // BDC
GrayLevelForNonStroking(0.753); // g
Rectangle(36.6, 465.43, 537.96, 24.84); // re
FillEvenOddRule(); // f*
EndMarkedContent(); // EMC
BeginMarkedContentPropList("/P", "<</Lang(x-none)/MCID 1>>"); // BDC
BeginText(); // BT
SelectFontAndSize("/F1", 18); // Tf
TextMatrix(1, 0, 0, 1, 39.6, 718.8); // Tm
GrayLevelForNonStroking(0); // g
GrayLevelForStroking(0); // G
ShowTextWithGlyphPos("[(GRA)29(NOTECH LI)-3(MIT)-4(ED)]"); // TJ
EndTextObject(); // ET
⽂章接下来的部分将对PDF⽂件的结构和解析过程进⾏更为详细的描述,接下来的章节包括:对象定义,⽂件结构,⽂件解析,⽂件读取,以及使⽤PDF⽂件分析器编程。
3. 免责声明
pdf ⽂件分析器能处理⼤量的⽂件,这是我在⾃⼰的系统上扫描众多PDF⽂件的经验。
不过,该程序不⽀持加密⽂件或者多个代⽂件(在对象不为零之前的第⼆个数字)。
在PDF规格⽂件之中可⽤功能的数量是⾮常显著的。
这并不可能为⼀个单的个开发者系统地测试所有的功能。
如果在整个⽂件分析期间该程序抛出⼀个异常,将显⽰⼀条错误信息,该信息显⽰源代码模块名和⾏号。
4.对象定义
PDF⽂件⽣成多个对象。
在PDF⽂件分析器项⽬中每个PDF对象都有⼀个对应的类。
所有这些对象类都派⽣于PDFbase类。
对象类定义源代码是BasicObjects.cs.确却地PDF对象定义在Adobe pdf⽂件规格第三章之中是有⽤的
4.1. 基础的对象
Boolean对象是靠PdfBoolean类来实现的. Boolean在PDF上的定义同C#上的是相同的.
Integer 对象是靠PdfInt类来实现的. PDF上的定义同C#上Int32的定义是相同的.
实数对象是靠PdfReal类来实现的. PDF上的定义同C#上的Single定义相同.
String 对象是靠PdfStr类来实现的. PDF上的定义同C#相⽐有所不同. String 是⽤字节构造出来的,⽽不是字符. 它被包在圆括号()⾥⾯. PdfFileAnalyzer会把包含在圆括号中的C#字符串保存成PDF的字符串. PDF的字符串对于ASCII编码⾮常有⽤.
⼗六进制字符串独享是靠PdfHex类来实现的. 它是由每字节两个⼗六进制数定义,并包在尖括号⾥⾯的字符串. PdfFileAnalyzer 将包含在尖括号中的C#字符串保存成PDF⼗六进制字符串. 对于 PDF 读取器,字符串和⼗六进制字符串对象可⽤于同种⽬的. 字符串 (AB) 等同于<4142>. PDF ⼗六进制字符串对于任意编码的场景⾮常有⽤.
Name 对象是靠PdfName类来实现的. Name 对象是由打头的正斜杠后⾯跟着⼀些字符组成的. 例如 /Width. Named 对象⽤作参数名称. PdfFileAnalyzer 将正斜杠开头的C#字符串保存成Name对象.
Null 对象是靠PdfNull类来实现的. PDF 对于null的定义基本上同C#中的是⼀样的.
4.2. 复合的对象
Array 对象是靠 PdfArray 类来实现的. PDF 数组是⼀个封装在⼀堆中括号中的对象的集合. ⼀个数组的对象可以是除了流之外的任何对象.PdfFileAnalyzer 将⼀个C#数组中的对象保存成PdfBase类
. 因为所有的对象都继承⾃PdfBase,所有在这个数组中保存多种类型的对象没有啥问题. 当数组对象被转换成⼀个字符串时(使⽤ToString()⽅法), 程序会在⾸位添加中括号. 数组可以是空的. 下⾯是⼀个有六个对象的数组⽰例: [120 9.56 true null (string) <414243>].
Dictionary 对象是靠PdfDict类实现的. PDF 字典是⼀组被包⼊⼀对双尖括号中的键值对集合. Dictionary 的键是⼀个对象的名称,⽽值则可以是除了流之外的任何对象. PdfFileAnalyzer 将⼀个键值对保存到PdfPair类中. 键是⼀个C#字符串,⽽值则是⼀个PdfBase.PdfDict 类有⼀个PdfPair类的数组. Dictionary 可以⽤键来访问. 因⽽键值对的顺序没有啥意义. PdfFileAnalyzer ⽤键来对键值对进⾏排序. 下⾯是⼀个有三个键值对的字典: <</CropBox [0 0 612 792] /Rotate 0 /Type
/Page>>.
Stream 对象是靠PdfStream来实现的. Streams 被⽤来处理⾯熟语⾔,图形和字体. PDF Stream 由⼀个字典和⼀个字节流组成. 字典中定义了流的参数. ⽐如流对象中字典的⼀个键值对 /Filter. PDF ⽂档定义了10种类型的过滤器. PdfFileAnalyzer ⽀持了4种. 这是我发现在实际场景中只会被⽤到那4种. 压缩过滤器 FlateDecode 是现在的PDF写⼊器最长被⽤到的过滤器. FlateDecode⽀持ZLib解压缩. LZWDecode 压缩过滤器在过去些年⽤的⽐较多. 为了能读取⽐较⽼的PDF⽂件, 我们的程序⽀持这个过滤器. ASCII85Decode 过滤器将可被打印的ASCII转换成⼆进制位. DCTDecode ⽤于JPEG图像的压
缩.PdfFileAnalyzer 为前三种实现了解压缩. DCTDecode 流则以⽂件扩展名.jpg保存. 它是⼀个可以被展⽰的图⽚⽂件.
Object 流在PDF 1.5中被引⼊. 它是⼀个包含多个间接对象(在下⾯会描述道)的流. 上⾯描述的Stream 对象⼀次只压缩⼀个流. Object 流会将所有包含进来的流压缩到⼀个压缩域中.
多引⽤流在PDF 1.5中被引⼊. 它是⼀个包含多引⽤表格的流,下⽂会描述到.
内联图⽚对象是靠 PdfInlineImage来实现的. 它是⼀个带有⼀个流的流. 内联图⽚是页⾯描述语⾔的⼀部分. 它由BI-开头图形, ID-图形数据和EI-结尾图形这三个操作符组成. BI 和 ID 之间的区域是⼀个图形字典,⽽ID 和 EI 之间的区域则包含图形数据. 4.3. 间接对象
间接对象是靠 PdfIndirectObject实现的. 它是⼀个PDF⽂档的主要构造块. 间接对象是任何被包在 “n 0 obj” 和 “endobj”之间的对象. 其它对象可以通过设定“n 0 R”来引⽤间接对象. “n”代表对象编号. “0”代表⽣成编号. 这个程序不⽀持0之外的⽣成编号. PDF 规范允许其它的编号. 多代⽣成的理念允许PDF的修改操作是在保留原有⽂件的基础上追加变更.
对象引⽤时⼀种引⽤间接对象的⽅法. 例如 /Pages 2 0 R 是⽬录对象中的字典⾥的⼀项. 它是⼀个指向 /Pages 对象的指针. pages对象是编号为2的间接对象.
4.4. 操作符和关键词
操作符和关键词不被认为是PDF对象. ⽽PdfFileAnalyzer 程序有⼀个PdfOp 和⼀个PdfKeyword 类可以从中得到 PdfBase 的类. 在转换过程中,转换器为每⼀个可⽤的字符序列创建了⼀个 PdfOp 或者PdfKeyword . Pdf⽂件规范的附录A-操作符总结中列出了所有的操作符. 列表中有73个操作符. 下⾯是⼀些操作符的⽰例: BT-打头的⽂本对象, G-⽤于做记号的设置灰度操作, m-移动到, re-矩形和Tc-设置字符间距. 下⾯是关键词的⽰例: stream, obj, endobj, xref.
5. ⽂件结构
PDF⽂件由四个部分构成: 头部Header , 主体body, 多引⽤cross-reference 和附带签名 trailer signature.
Header: 头部是⽂件的签名. 它必须是 %PDF-1.x , x 从 0 到 7.
Body: 主体区域包含所有的间接对象.
Cross-reference: 多引⽤是⼀个指向所有间接对象的⽂件位置指针列表. 有两种类型的多引⽤表格. 原始的类型有ASCII 字符组成. 新式的是⼀个包含⼀个间接对象的流. 信息以⼆进制数字编码. 在多引⽤表格的结束部分有⼀个附件字典. ⼀个⽂件可以有超过⼀个的多引⽤区域.
Trailer signature: 附带签名由关键词“startxref”, 最后⼀个多引⽤表格的偏移位, 和结束签名 %%EOF 组成. 请注意: 附带签名是多引⽤区域的⼀部分.
6. ⽂件转换
PDF ⽂件是⼀个字节的序列. ⼀些字节有特殊的意义.
空格被定义成: null, tab, 换⾏, 换页, 回车和间隔.
分隔符被定义成: (, ), <, >, [, ], {, }, /, %, 以及空格字符.
⽂件转换是由PdfParser 类来完成的. 开始进⾏转换过程是,程序会设置⽂件需要被转换区域的位置. ParseNextItem() 是提取下⼀个对象的⽅法.
解析器跳过空格符和注释。
如果下⼀个字节是“(”,判断对象为⼀个字符串。
如果下⼀个字节是“[”,判断对象是⼀个数组。
如
果接下来的两个字节是“<<”,判断对象是⼀个字典。
如果下⼀个字节是“<”,判断对象是⼀个⼗六进制字符串。
如果下⼀个字节是“/”,判断对象是⼀个名称。
如果下⼀个字节不是上述任何⼀种,解析器会采集随后的字节直到发现定界符。
定界符不是当前标记符的⼀部分。
标记符可以是整数,实数,操作符或关键词。
在整数的情况下,程序将进⼀步搜索对象引⽤“n 0 R”或间接对象“n 0 obj”中 n 为该整数的对象。
从 ParseNextItem() 返回的值是第4节“对象的定义”中所述的适当对象。
对象的类作为PdfBase 类返回。
在数组或字典的情况下,程序将执⾏递归调⽤ ParseNextItem() 来解析数组或字典的内部对象。
7. ⽂件读取
PdfDocument 类是 PDF ⽂件分析的主要类。
⼊⼝⽅法是 ReadPdfFile(String FileName)。
程序以⼆进制读取的⽅式打开 PDF ⽂件(⼀次⼀个字节)。
⽂件分析开始于检查头部签名 %PDF-1.x(x为0到7)和结尾签名%%EOF。
有⼈会认为,所有的 PDF ⽣成器会把头部签名放在⽂件的零位置,结尾签名放在⽂件的最后。
不幸的是,实际并⾮如此。
程序必须在⽂件的两端搜索这两个签名。
如果头部签名不在零位置,所有间接对象的⽂件位置的指针也必须调整。
就在结尾签名的前⾯有⼀个指向最后⼀个交叉引⽤表开始位置的指针。
解析器为多引⽤表设置⽂件位置. 如果下⼀个对象是“xref” 关键词,我们就有了原来类型的多引⽤. 否则,它就是新的基于流的多引⽤. ⽂件可以有多个多引⽤表. ⽂件也可以同时拥有新的和旧的风格的表. 每⼀个表都有⼀个对象数⽬和指向间接引⽤开头的指针的列表. 对于每⼀个活动对象程序都会创建⼀个PdfIndirectObject 对象并将其保存在 ObjectArray中. 除了对象的数字和位置,这个对象的其它东西都是空的. 对于原来的多引⽤表,其位置是相对于⽂件⽽⾔的. 对于流类型的多引⽤,位置是相对于⼀个⽗间接对象流⽽⾔的.
在处理过程中,如果间接对象⽣成了0之外的数字, 程序的执⾏就会被终⽌. PdfFileAnalyzer 不⽀持多代的形式.
附件字典在交叉引⽤表的末尾处。
分析PDF⽂件的时候,我们创建了⼀个带负对象号的虚拟间接对象⽤于保存附件字典。
程序在附件字典中寻找四个特定的⼊⼝。
如果找到/Encrypt⼊⼝,表⽰PDF⽂件是被加密的,程序的将结束分析,因为程序不⽀持分析加密格式的PDF⽂件。
接着程序寻找/Root⽬录对象的对象号。
如果找到/XRefStm⼊⼝,我们就有了两种交叉引⽤的类型。
最后如果存在/Prev⼊⼝,我们有了另⼀个⽤于处理的交叉引⽤表。
交叉引⽤的处理完成后,我们拥有所有的间接对象的数组。
在处理阶段,可⽤信息是对象号和对象位置。
下⼀步,程序遍历数组,读取并解析每⼀个间接对象,并设置对象的值。
如果对象是流,仅字典部分被解析,因为在这个时候还不知道流的长度。
除了上述对象,如果字典和流对象的对象类型和⼦类型成员是可⽤的,系统将为字典和流对象设置这两个值。
接下来程序遍历所有的对象,并处理流对象。
流对象的对象类型是"/ObjStm"。
程序读取和对象相关联的流,并分解流到多个间接对象上。
接下来程序搜索所有的字典对象和流对象引⽤的对象字典对象。
程序查找键值对,例如“/name n 0 R”。
加⼊键值对被找到,程序检查对象类型。
如果再对象解析阶段没有设置对象类型,对象类型将设置为/name值。
下⼀步,读取所有前⾯没有读取的流。
系统读取从⽂件读取流。
流被解码并保存到对应的⽂件中。
PdfFileAnalyzer⽀持如下的过滤:/FlateDecode,/LZWDecode, /ASCII85Decode和/DCTDecode。
⽂本⽂件的扩展名是.txt,⼆进制⽂件的扩展名
是.bin,图⽚⽂件的扩展名是.jpg和.bmp,字体⽂件的扩展名是.ttf,交叉引⽤⽂件的扩展名是.xref。
/FlateDecode是ZLib Deflate 压缩算法。
下⼀步是构建页的内容。
程序跟随从根开始的页⾯树。
页对象不是流对象。
换句话说,页描述命令是不能直接在也对象中的。
页对象字典有/Contents的键值对。
如果不存在这个键值对,那么页⾯就是空的。
内容⼊⼝值可以是⼀个单独的引⽤或者是⼀个应⽤数组。
程序将为来⾃于⼀个或多个内容流的页⾯创建虚拟的内容流。
页内容虚拟流保存在PageObj_xx.txt和PageSource_xx.txt中。
PageObj_xx.txt是页⾯的实际描述内容。
PageSource_xx.txt是将页⾯的描述内容转换为伪C#源代码。
在第⼆节概要中,有这两个⽂件的例⼦。
页内容流是由参数和操作符组成的。
例如矩形由四个实数描述的,内嵌的图⽚不遵循这个规则。
它的描述是在第三节对象定义中。
最后,程序产⽣对象汇总⽂件ObjectSummary.txt。
⽂件显⽰所有简介对象的信息不包含流。
8. PdfFileAnalyzer 程序
开发应⽤程序 PdfFileAnalyzer 的⽬的是⽤来测试这个 PDF ⽂件解析类。
如果你想在开发环境之外测试它的可执⾏程序,需创建⼀个名为 PdfFileAnalyzer 的⽬录并复制 PdfFileAnalyzer.exe 到这个⽬录中,然后运⾏这个程序。
如果你想从 Visual C#开发环境中运⾏这个项⽬,请确保你在“项⽬属性”的“Debug”标签栏中定义了⼀个⼯作⽬录。
此程序是使⽤ Microsoft Visual
C# 2012 开发的。
运⾏程序,可⽤的操作项有: Open, Setup 和 Exit.
程序⾸次执⾏时你必须使⽤ Setup 定义⼯程⽬录。
这个⽬录盛放所有被分析的 PDF ⽂件所产⽣的对应⼦⽬录。
Open 按钮会显⽰⼀个标准的⽂件选择对话框,你可以在其中找到你要进⾏分析的 PDF ⽂件。
PDF⽂件分析器界⾯将切换到类的汇总界⾯:
每⾏代表⼀个间接的PDF对象。
每列是:
Object No. 间接对象号。
对于附件字典来说dummy号,对象号是⼀个,对象号是负数时,在界⾯上显⽰为TRn
Ojbect 在第4节中定义的对象类型
Type 如果对象是字典或者流,类型是/Type字典的值。
如果类型不是字典或者字典不包含/Type,显⽰值来⾃于对这个对象的间接引⽤
Subtype 如果对象是字典或者流,或者字典包含/Subtype,将显⽰在这⼀列
Parent Object No. 如果间接对象是对象流的⼀部分(见4.2节复合对象),这⼀列显⽰流对象的对象号
Parent Index 如果间接对象是对象流的⼀部分,索引号是⽗对象流的号
File Name 流对象和页⾯对象存在⽂件名。
File Name是⽂件存储在流对象内的名字。
⽂件有如下的扩展名:.txt是⽂本⽂件,.bin是⼆进制⽂件,.bmp是图⽚,.jpg是图⽚,.ttf是字体,.xref是多引⽤流。
如果分析MyFile.PDF的流⽂件,⼯程⽬录的⼦⽬录MyFile将被指定在启动界⾯上。
页⾯对象不是流。
⽂件表⽰这⼀页所有对象的关联关系
Ojbect Position 如果间接对象⽂件不是对象流类型,这是对象在PDF⽂件内的位置。
如果间接对象是对象流的⼀部分,这对象在⽗对象内的位置。
位置按照⼗进制和⼗六进制数字显⽰,便于程序员再⼆进制编辑器中查看PDF⽂件 Stream Position 和 Stream Length 流的位置和长度。
流的位置是相对于⽂件或者⽗对象的,同对象的位置使⽤相同的计算⽅法
点击Summary按钮,查看ObjectSummary.txt ⽂件。
选择⼀⾏并点击View按钮或者双击⼀⾏后将显⽰对象分析界⾯,⽤于查看间接对象的详情。
对于所有的⾮流对象,前⾯的三个按钮是不能点击的。
仅仅显⽰对象⾃⾝的信息。
你能⽤⽂本⽅式或者⼗六进制格式查看这些信息。
对于流对象,第⼀个按钮的名字是object type。
前两个按钮object type和Stream允许你在查看对象和流之间切换。
Hex和Text 按钮允许你采⽤⼆进制格式或者⽂本格式查看。
如果是图⽚流,⽂本格式显⽰为四列:(1) 对象号,(2) 类型 (0-未使⽤,1-普通对象,2-流对象),(3)普通对象的位置和流对象的⽗对象,(4) ⽗对象的索引号。
如果是⼆进制流(例如:字体),则仅能⽤⼗六进制格式查看。
页⾯对象按照流对象来处理。
所有内容对象的⽂本显⽰是关联的。
另外,Source按钮允许你查看页⾯在C#代码中的描述语⾔。
JPG图⽚和BMP图⽚可以旋转⽅向和调整⼤⼩。