Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析

转载http://www.cnblogs.com/mayswind/archive/2013/03/17/2962205.html

【题外话】

这是2010年参加比赛时候做的研究,当时为了实现对Word、Excel、PowerPoint文件文字内容的抽取研究了很久,由于Java有POI库,可以轻松的抽取各种Office文档,而.NET虽然有移植的NPOI,但是只实现了最核心的Excel文件的读写,所以之后查了很多资料才实现了Word和PowerPoint文件文字的抽取。之后忙于各种事情一直没时间整理,后来虽然想写成文章但由于时间太久也记不清很多细节,现在重新查找资料并整理如下,希望对大家有用。

 

【系列索引】 

  1. Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析(一)
    获取Office二进制文档的DocumentSummaryInformation以及SummaryInformation
  2. Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析(二)
    获取Word二进制文档(.doc)的文字内容(包括正文、页眉、页脚、批注等等)
  3. Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析(三)
    详细介绍Office二进制文档中的存储结构,以及获取PowerPoint二进制文档(.ppt)的文字内容
  4. Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析(完)
    介绍Office Open XML文档(.docx、.pptx)如何进行解析以及解析Office文件常见开源类库

 

【文章索引】

  1. .NET下读取Office文件的方式
  2. Windows复合二进制文件及其Header
  3. 我们从Directory开始
  4. DocumentSummaryInformation和SummaryInformation
  5. 相关链接

 

【一、.NET下读取Office文件的方式】

10年的时候参加比赛要做一个文件检索的系统,要包含Word、PowerPoint等文件格式的全文检索。由于之前用过.NET并且考虑到这些是微软的格式,可能使用.NET读取会更容易些,但没想到.NET这边查到的资料只有Interop的方式读取Office文件。后来接触了Java的POI,发现.NET也有移植的NPOI,但是只移植了核心的Excel读写,并没有Word、PowerPoint等文件的读写,所以最后没有办法只能硬着头皮自己去做Word和PowerPoint文件的解析。

那么Interop是什么?Interop的全称是“Interoperability”,即微软希望托管的.NET能与非托管的COM进行互相调用的一种方式。通过Interop读写Office即调用安装在计算机上的Office软件来实现Office的读写,其优点显而易见,文件还是由Office生成或读取的,所以与自己打开Office是没有任何区别的;但缺点也非常明显,即运行程序的计算机上必须安装有对应版本的Office软件,同时操作Office文件时实际上是打开了对应的Office组件,所以运行效率低、耗内存大并且还可能产生内存泄露的问题。关于Interop方式读写Office文件的例子网上有很多,有兴趣的可以自行查阅,这里就不再多讲了。

那么,有没有方式不借助Office软件实现Office文件的读写呢?答案肯定是肯定的,就像Java中的POI及.NET中的NPOI实现的那样,即通过程序自己读写文件来实现Office文件的读写。不过由于Office文件结构非常复杂,这里只提供文件摘要信息和文件文本内容的解析。不过即使如此,对于全文检索什么的还是足够的。

 

【二、Windows复合二进制文件以及Header】

前几年,微软开放了一些私有格式的规范,使得所有人都可以对其文件进行解析,而不需要支付任何费用,这也使得我们编写解析文件的程序成为可能,相关链接在文章最后可以找到。对于一个Microsoft Office文件,其实质是一个Windows复合二进制文件(Windows Compound Binary File),文件的头Header是固定的512字节,Header记录文件最重要的参数。Header之后可以分为不同的Sector,Sector的种类有FAT、Mini-FAT(属于Mini-Sector)、Directory、DIF、Stroage等五种。为了方便称呼,我们规定每个Sector都有一个SectorID,Header后的Sector为第一个Sector,其SectorID为0。

我们先来说Header,一个Header的部分截图及包含的信息如下,比较重要的用粗体表示。

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第1张图片

  1. Header的前8字节Byte[],也就是整个文件的前8字节,都是固定的0xD0 0xCF 0x11 0xE0 0xA1 0xB1 0x1A 0xE1,如果不是则说明不是复合文件。
  2. 从008H到017H的16字节,是Class Id,不过很多文件都置的0。
  3. 从018H到019H的2字节UInt16,是文件格式的次要版本。
  4. 从01AH到01BH的2字节UInt16,是文件格式的主要版本。
  5. 从01CH到01DH的2字节UInt16,是固定为0xFE 0xFF,表示文档使用的是Little Endian(低位在前,高位在后)。
  6. 从01EH到01FH的2字节UInt16,是Sector大小的幂,默认为9(0x09 0x00),即每个Sector为512字节。
  7. 从020H到021H的2字节UInt16,是Mini-Sector大小的幂,默认为6(0x06 0x00),即每个Mini-Sector为64字节。
  8. 从022H到023H的2字节UInt16,是预留的,必须置0。
  9. 从024H到027H的4字节UInt32,是预留的,必须置0。
  10. 从028H到02BH的4字节UInt32,是预留的,必须置0。
  11. 从02CH到02FH的4字节UInt32,是FAT的数量。
  12. 从030H到033H的4字节UInt32,是Directory开始的SectorID。
  13. 从034H到037H的4字节UInt32,是用于事务的,必须置0。
  14. 从038H到03BH的4字节UInt32,是最小串(Stream)的最大大小,默认为4096(0x00 0x10 0x00 0x10)。
  15. 从03CH到03FH的4字节UInt32,是MiniFAT表开始的SectorID
  16. 从040H到043H的4字节UInt32,是MiniFAT表的数量。
  17. 从044H到047H的4字节UInt32,是DIFAT开始的SectorID
  18. 从048H到04BH的4字节UInt32,是DIFAT的数量。
  19. 从04CH到1FFH的436字节UInt32[],是前109块FAT表的SectorID。

那么我们可以写如下的代码将Header中重要的内容解析出来。

View Code

说个比较有意思的,.NET中的BinaryReader有很多读取的方法,比如ReadUInt16、ReadInt32之类的,只有ReadUInt16的Summary写着“使用 Little-Endian 编码...”(见下图),其实不仅仅是ReadUInt16,所有ReadIntX、ReadUIntX、ReadSingle、ReadDouble都是使用Little-Endian编码方式从流中读的,大家可以放心使用,而不需要一个字节一个字节的读再反转数组,我在10年的时候就走过弯路。解释在MSDN各个方法中的备注里:http://msdn.microsoft.com/zh-cn/library/vstudio/system.io.binaryreader_methods.aspx

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第2张图片

 

【三、我们从Directory开始】

复合文档中其实存放着很多内容,这么多内容需要有个目录,那么Directory就是这个目录。从Header中我们可以读取出Directory开始的SectorID,我们可以Seek到这个位置(0x200 + sectorSize * dirStartSectorID)。Directory中每个DirectoryEntry固定为128字节,其主要结构如下:

  1. 从000H到040H的64字节,是存储DirectoryEntry名称的,并且是以Unicode存储的,即每个字符占2个字节,其实可以看做是UInt16。
  2. 从041H到042H的2字节UInt16,是DirectoryEntry名称的长度(包括最后的“\0”)。
  3. 从042H到042H的1字节Byte,是DirectoryEntry的类型。(主要的有:1为目录,2为节点,5为根节点)
  4. 从044H到047H的4字节UInt32,是该DirectoryEntry左兄弟的EntryID(第一个DirectoryEntry的EntryID为0,下同)。
  5. 从048H到04BH的4字节UInt32,是该DirectoryEntry右兄弟的EntryID。
  6. 从04CH到04FH的4字节UInt32,是该DirectoryEntry一个孩子的EntryID。
  7. 从074H到077H的4字节UInt32,是该DirectoryEntry开始的SectorID。
  8. 从078H到07BH的4字节UInt32,是该DirectoryEntry存储的所有字节长度。

显然,Directory其实是一个树形的结构,我们只要从第一个Entry(Root Entry)开始递归搜索就可以了。

为了方便开发,我们创建一个DirectoryEntry的类

View Code

然后我们递归搜索就可以了

View Code

 

【四、DocumentSummaryInformation和SummaryInformation

Office文档包含很多摘要信息,比如标题、作者、编辑时间等等,如下图。

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第3张图片

摘要信息又分为两类,一类是DocumentSummaryInformation,另一类是SummaryInformation,分别包含不同种类的摘要信息。通过上述的代码应该能获取到Root Entry下有一个叫“\005DocumentSummaryInformation”的Entry和一个叫“\005SummaryInformation”的Entry。

对于DocumentSummaryInformation,其结构如下

  1. 从018H到01BH的4字节UInt32,是存储属性组的个数。
  2. 从01CH开始的每20字节,是属性组的信息:
    • 对于前16字节Byte[],如果是0x02 0xD5 0xCD 0xD5 0x9C 0x2E 0x1B 0x10 0x93 0x97 0x08 0x00 0x2B 0x2C 0xF9 0xAE,则表示是DocumentSummaryInformation;如果是0x05 0xD5 0xCD 0xD5 0x9C 0x2E 0x1B 0x10 0x93 0x97 0x08 0x00 0x2B 0x2C 0xF9 0xAE,则表示是UserDefinedProperties。
    • 对于后4字节UInt32,则是该属性组相对于Entry的偏移。

对于每个属性组,其结构如下:

  1. 从000H到003H的4字节UInt32,是属性组大小。
  2. 从004H到007H的4字节UInt32,是属性组中属性的个数。从008H开始的每8字节,是属性的信息:
    • 对于前4字节UInt32,是属性编号,表示属性的种类。
    • 对于后4字节UInt32,是属性内容相对于属性组的偏移。

常见的属性编号有以下这些:

View Code

对于每个属性,其结构如下:

  1. 从000H到003H的4字节UInt32,是属性内容的类型。
    • 类型为0x02时为UInt16。
    • 类型为0x03时为UInt32。
    • 类型为0x0B时为Boolean。
    • 类型为0x1E时为String。
  2. 剩余的字节为属性的内容。
    1. 除了类型是String时为不定长,其余三种均为4位字节(多余字节置0)。
    2. 类型是String时前4字节是字符串的长度(包括“\0”),所以没法使用BinaryReader的ReadString读取。之后长度为字符串内容,字符串是使用单字节编码进行存储的,可以使用Encoding中的GetString获取字符串内容。

为了方便开发,我们创建一个DocumentSummary的类。比较有意思的是,不论DocumentSummaryInformation还是SummaryInformation,第一个属性都是记录该组内容的代码页编码,可以通过Encoding.GetEncoding()获取对应的编码然后用GetString把对应的字符串解析出来:

View Code

然后我们进行读取就可以了:

View Code

而SummaryInformation与DocumentSummaryInformation相比读取方式是一样的,只不过属性组的16位标识为0xE0 0x85 0x9F 0xF2 0xF9 0x4F 0x68 0x10 0xAB 0x91 0x08 0x00 0x2B 0x27 0xB3 0xD9。

常见的SummaryInformation属性的属性编号如下:

View Code

其他代码由于与DocumentSummaryInformation相近就不再单独给出了。

附,本文所有代码下载:http://files.cnblogs.com/mayswind/DotMaysWind.OfficeReader_1.rar

 

【五、相关链接】

1、Microsoft Open Specifications:http://www.microsoft.com/openspecifications/en/us/programs/osp/default.aspx
2、用PHP读取MS Word(.doc)中的文字:https://imethan.com/post-2009-10-06-17-59.html
3、Office檔案格式:http://www.programmer-club.com.tw/ShowSameTitleN/general/2681.html
4、LAOLA file system:http://stuff.mit.edu/afs/athena/astaff/project/mimeutils/share/laola/guide.html

 

【题外话】

上篇文章很荣幸被NPOI的大神回复了,同时也纠正了我一个问题,就是NPOI其实是有doc文件的解析,只不过一直没有跟随正式版发布过,要获取这部分代码,可以移步CodePlex(http://npoi.codeplex.com/),访问在SourceCode中的NPOI.ScratchPad中即可看到。给大家造成的不便在此表示抱歉。

【文章索引】

  1. WordDocument和FIB
  2. Table Stream中的Piece Table
  3. 正式获取文字内容
  4. 相关链接

 

【一、WordDocument和FIB】

我们接着第一篇的代码继续,不知大家有没有查看过Directory获取到的内容,比如上次的文档摘要SummaryInformation和DocumentSummaryInformation,除此之外还有专门存储文档内容的DirectoryEntry,比如Word的为“WordDocument”和“1Table”,PowerPoint的为“PowerPoint Document”,Excel的为“Workbook”。

我们先从WordDocument说起。不知大家发现了没有,其实不论是哪个Word文件,WordDocument这个DirectoryEntry的SectorID总是0,也就是说,WordDocument其实就是Header之后的第一个Sector。对于WordDocument,其最重要的应该是其中包含的FIB(File Information Block)了,FIB位于WordDocument的开头,其包含着Word文件非常重要的参数,诸如文件的加密方式、文字的编码等等。

对于一个FIB,官方文档中说是可变长的,其中FIB中最开头的为固定32字节长的FibBase:

  1. 从000H到001H的2字节UInt16,是固定为0xA5EC,表明文档为Word二进制文件。
  2. 从002H到003H的2字节UInt16,是Word格式的版本(nFib),但实际上这里一般为0xC1,即Word97的格式,真实的版本在之后会出现。
  3. 从00AH到00BH的2字节UInt16,其实这个UInt16实际被分为了13部分,除了第5部分占了4bit外,其余12部分各站1bit,总计16bit,我们可以通过位运算分别读取每一bit的值,比如Boolean isDot = ((n & 0x1) == 1),就可以读取最低位是否为真了。插张图来说明下13部分是如何分配的,最左为UInt16的最低位。

    • A(第0位),为文档是否是.Dot文件(Word模板文件)
    • B(第1位),没明白这一位存的是什么。
    • C(第2位),为文档是否是复杂格式(快速保存时生成的格式)。
    • D(第3位),为文档是否包含图片。
    • E(第4-7位),当nFib小于0x00D9时为快速保存(Quick Save)的次数,当大于0x00D9时始终为0x0F。
    • F(第8位),为文档是否加密。
    • G(第9位),为1时文字存储于1Table,为0时文字存储于0Table。
    • H(第10位),为是否“建议以只读方式打开文档”(保存时选择“工具”->“常规选项”可以设置该属性)。
    • I(第11位),为是否有写保护密码。
    • J(第12位),为固定值1。
    • K(第13位),为是否要用应用程序的语言默认值覆盖段落格式中定义的语言和字体。
    • L(第14位),为文档语言是否为东亚语言。
    • M(第15位),当文档加密时,文档如果使用XOR混淆则为1,否则为0;文档不加密时忽略该属性。
  4. 从00CH到00DH的2字节UInt16,为固定的0x00BF或0x00C1(某些语言的Word97会为0x00C1)
  5. 从00EH到011H的4字节UInt32,当文档加密并且混淆,则为混淆的密钥;如果加密不混淆,则为加密头的长度;否则应置0。
  6. 从012H到012H的1字节Byte,应当置0,并且忽略。
  7. 从013H到013H的1字节Byte,被划分为6部分,除了第6部分占3bit之外,其余各占1bit。
    • 第1位,必须置0,并且忽略。
    • 第2位,通过右键菜单->新建->新建Word文件创建的空文件为1,其余应当为0。
    • 第3位,为是否要用应用程序的默认值覆盖页面中的页面大小、页面方向、页边距等。
    • 第4位和第5位,未定义,应当忽略。
    • 第6-8位,未定义,应当忽略。
  8. 从014H到015H和016H到017H的各2字节,应当置0,并且忽略。
  9. 从018H到01BH和01CH到01FH的各4字节,未定义,应当忽略。

那FibBase之后呢?其实FIB包含很多的内容,从FibBase开始按顺序分别是:

  1. 2字节的UInt16,为之后FibRgW97块中16位整数的个数,固定为0x000E。
  2. 28字节的FibRgW97块,包含14个UInt16。
  3. 2字节的UInt16,为之后FibRgLw97块中32位整数的个数,固定为0x0016。
  4. 88字节的FibRgLw97块,包含22个UInt32。
  5. 2字节的UInt16,为之后FibRgFcLcb块中64位整数的个数(但FibRgFcLcb实际存储的是32位整数)。
    • 如果文档为Word97,该项为0x005D。
    • 如果文档为Word2000,该项为0x006C。
    • 如果文档为Word2002,该项为0x0088。
    • 如果文档为Word2003,该项为0x00A4。
    • 如果文档为Word2007,该项为0x00B7。
  6. 不定长的FibRgFcLcb块,包含不定个数的32位UInt32(数量也就是上述个数的2倍),但可见至少拥有186个。
  7. 2字节的UInt16,为之后FibRgCswNew块中16位整数的个数。
    • 如果文档为Word97,该项为0x00(实际上不包含FibRgCswNew)。
    • 如果文档为Word2000-2003,该项为0x02。
    • 如果文档为Word2007,该项为0x05。
  8. 不定长的FibRgCswNew块,首先是固定长度的UInt16即Word文档的真实版本nFibNew,然后一个UInt16表示文档在完整存档后快速存档的次数,之后如果是Word2007则还有3个UInt16文档说没有定义且要求忽略(大囧)。

看完FIB结构后我们先来看下nFib与文件版本对应的情况:

  1. 0x00C1(nFib)表示文件为Word97(或者为更高版本的文档)。
  2. 0x00D9(nFibNew)表示文件为Word2000。
  3. 0x0101(nFibNew)表示文件为Word2002。
  4. 0x010C(nFibNew)表示文件为Word2003。
  5. 0x0112(nFibNew)表示文件为Word2007。

由于FIB中内容实在太多了,之后的部分就不再介绍了,不过为了读取文档的内容我们还应该看看如下的内容(当然也不一定都用到)。

  1. FibRgW97中的14个UInt16,为文档的语言(lidFE),比如0x0804为简体中文。如果文档是Unicode存储的当然无所谓,如果是ANSI码存储的那么就需要获取这个了。
  2. FibRgLw97中的第1个Int32,为Word Document中有意义的字节数(即Word Document之后的字节数都可以忽略)。
  3. FibRgLw97中的第4个Int32,为文档中正文(Main document)的总字数。
  4. FibRgLw97中的第5个Int32,为文档中页脚(Footnote subdocument)的总字数。
  5. FibRgLw97中的第6个Int32,为文档中页眉(Header subdocument)的总字数。
  6. FibRgLw97中的第7个Int32,为文档中批注(Comment subdocument)的总字数。
  7. FibRgLw97中的第8个Int32,为文档中尾注(Endnote subdocument)的总字数。
  8. FibRgLw97中的第10个Int32,为文档中文本框(Textbox subdocument)的总字数。
  9. FibRgLw97中的第11个Int32,为文档中页眉文本框(Textbox Subdocument of the header)的总字数。
  10. FibRgFcLcb中的第67个UInt32,为Piece Table在Table Stream中的偏移(fcClx)。
  11. FibRgFcLcb中的第68个UInt32,为Piece Table的字节数(lcbClx)。

以上这些信息我们可以编写如下代码获取:

View Code

 

【二、Table Stream中的Piece Table】

Table Stream其实就是1Table或者0Table的总称,具体文字存在那个Table中还要根据FIB中的信息。由于复合文件是以一个个Sector形式存储的,所以我们首先需要获取文字存储在哪些个Sector中。实际上,文本的存储是由Piece Element(暂且这么叫吧)控制着,包括是否启用Unicode、每块的位置等等,这些内容都存放于Table Stream中的Piece Table中,Piece Table相对Table Stream的偏移量可以从FIB中获取到。

关于Piece Element,官方是这么描述的:

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第4张图片

看上去这么多,其实我们需要的仅是fc中定义的是否使用Unicode存储文本(fc中第31位为0则为Unicode,为1则为Ansi),以及文本相对于WordDocument的偏移量(fc中低位30位),我们首先对Piece Element定义一个类,可以看出,一个Piece Element的大小实际为2 + 4 + 2 = 8字节:

View Code

 

然后我们来看Piece Table,其结构为:

  1. 从000H到000H的1字节Byte,是Piece Table的标识,为固定的0x02。
  2. 从001H到004H的4字节UInt32,是Piece Table的大小(即存储文字的Sector的数量)。
    官方给了一个Piece Table中个数的计算公式
    其中,cbPlc即Piece Table的大小,cbData为一个Piece Element的大小,所以Piece Table中的个数实际为n = (size - 4) / 12。
  3. 之后4*(n + 1)个字节,是每个Piece Element存储的文本的开始位置(结束位置即下一个的开始位置)。
  4. 之后8*n个字节,是每个Piece Element的相关信息。

Piece Table信息我们可以编写如下代码获取:

View Code

 

【三、正式获取文本内容】

上头我们可以获取到Word中文本的开始和结束位置,其实一个Word文档中,文字是按如下顺序存储的:

  1. 正文内容(Main document)
  2. 页脚(Footnote subdocument)
  3. 页眉(Header subdocument)
  4. 批注(Comment subdocument)
  5. 尾注(Endnote subdocument)
  6. 文本框(Textbox subdocument)
  7. 页眉文本框(Textbox Subdocument of the header)

所以,我们可以根据FibRgLw97中获取的每一部分的字数以及Piece Table中起始的位置来获取每一部分的文字。

比如正文内容的位置为[0, ccpText],页脚的位置为[ccpText + 1, ccpText + 1 + ccpFtn]……

所以我们编写如下代码获取:

View Code

不过需要注意的是,由于Word文档中的换行为“\r”(CR),而Windows中的换行符为“\r\n”(CR+LF),所以获取文字后需要将“\r”替换为“\r\n”,否则换行将无法正常显示,除此之外,还有其他的一些特殊字符也需要替换或处理。

附,本文所有代码下载:http://files.cnblogs.com/mayswind/DotMaysWind.OfficeReader_2.rar

 

【四、相关链接】

1、Microsoft Open Specifications:http://www.microsoft.com/openspecifications/en/us/programs/osp/default.aspx
2、用PHP读取MS Word(.doc)中的文字:https://imethan.com/post-2009-10-06-17-59.html
3、Office檔案格式:http://www.programmer-club.com.tw/ShowSameTitleN/general/2681.html
4、LAOLA file system:http://stuff.mit.edu/afs/athena/astaff/project/mimeutils/share/laola/guide.html


【题外话】

我突然发现现在做Office文档的解析要比2010年的时候容易得多,因为文档从2010年开始更新了好多好多次,读起来也越来越容易。写前两篇文章的时候参考的好多还是微软的旧文档(2010年的),写这篇的时候重下了所有的文档,发现每个文档都好读得多,整理得也更系统,感觉微软真的是用心在做这个开放的事。当然,这些文档大部分也是2010年的时候才开始发布出来的,仔细想想当年还是很幸运的。

【文章索引】

  1. 奇怪的文档与FAT和DIFAT
  2. 奇怪的DocumentSummary和Summary
  3. PowerPoint Document的结构与解析
  4. 相关链接

 

【一、奇怪的文档与FAT和DIFAT】

在刚开始做解析的时候,大都是从Word文档(.doc)入手,而doc文档没有太多复杂的东西,所以按照流程都可以轻松做到,也不会出现什么差错。但是做PowerPoint解析的时候就会遇到很多问题,比如如果按第一节讲的进行解析Directory的话会发现,很多PowerPoint文档是没有DocumentSummaryInformation的,这还不是关键,关键是,还有一部分甚至连PowerPoint Document都没有,见下图。

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第5张图片

其实这种问题不光解析PowerPoint的时候会遇到,解析Excel的时候同样会遇到,那么这到底是什么问题呢?其实我们在读取Directory时,认为Directory所在的Sector是按EntryID从小到大排列的,但实际上DirectoryEntry并不一定是这样的,并且有的Entry所在的Sector有可能在RootEntry之前。

不知大家是否还记得FAT和DIFAT这两个结构,虽然从第一篇就读取了诸如开始的位置和个数,但是一直没有使用,那么本篇先详细介绍一下这俩结构。

首先来看下微软的文档是如何描述这俩结构的:

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第6张图片

我们可以看到,FAT、DIFAT其实是4字节的结构,那他们有什么作用呢?我们知道,Windows复合文档是以Sector为单位存储的文档,但是Sector的顺序并不一定是存储的前后顺序,所以我们需要有一个记录着所有Sector顺序的结构,那么这个就是FAT表。

那么FAT表里存储的是什么呢?FAT表其实本身也是一个Sector,只不过这个Sector存储的是其他Sector的ID,即每个FAT表存储了128个SectorID,并且这个顺序就是Sector的实际顺序。所以,获取了所有的FAT表,然后再获取所有的SectorID,其实就获取了所有Sector的顺序。当然,我们其实只需要存储所有FAT表的SectorID就行,然后根据根据SectorID在FAT表中查找下一个SectorID就可。

还记得第一篇读取文件头Header么?在文件头的最后有109块指向FAT表的SectorID,经过计算,如果这109个FAT表全部填满,那么一共可以包括109 * 128个SectorID,也就是除了文件头一共有109 * 128 * 512字节,所以整个文件最多是512 + 109 * 128 * 512 = 7143936 Byte = 6976.5 KB = 6.81 MB。如果文件再大怎么办?这时候就有了DIFAT,DIFAT是记录剩余FAT表的SectorID的,也就是相当于Header中109个FAT表的SectorID的扩充。所以,我们可以通过文件头Header和DIFAT获取所有FAT表的SectorID,然后通过这些FAT表的SectorID再获取所有的Sector的顺序。

首先我们获取文件头中前109个FAT表的SectorID:

View Code

需要说明的是,这里并没有判断FAT的数量是否大于109块,因为如果FAT为空,则标识为FreeSector,即0xFFFFFFFF,所以读取到FreeSector时表明之后不再有FAT,即可以退出读取。所有常见的标识见下。

protected const UInt32 MaxRegSector = 0xFFFFFFFA;protected const UInt32 DifSector = 0xFFFFFFFC;protected const UInt32 FatSector = 0xFFFFFFFD;protected const UInt32 EndOfChain = 0xFFFFFFFE;protected const UInt32 FreeSector = 0xFFFFFFFF;

如果FAT的数量大于109,我们还需要通过读取DIFAT来获取剩余FAT的位置,需要说明的是,每个DIFAT只存储127个FAT,而最后4字节则为下一个DIFAT的SectorID,所以我们可以通过此遍历所有的FAT。

View Code

文章到这,大家应该能明白接下来做什么了吧?之前由于“理所当然”地认为Sector的顺序就是存储的顺序,所以导致很多DirectoryEntry无法读取出来。所以现在我们应该首先获取DirectoryEntry所占Sector的真实顺序。

View Code

然后获取每个DirectoryEntry偏移的方法也应该改为:

View Code

这样所有的DirectoryEntry就都能获取到了。

 

【二、奇怪的DocumentSummary和Summary】

在能真正获取所有的DirectoryEntry之后,不知道大家发现了没有,很多文档的DocumentSummary和Summary却还是无法获取到的,一般说来就是得到SectorID后Seek到指定位置后读到的数据跟预期的有太大的不同。不过有个很有意思的事就是,这些无法读取的DocumentSummary和Summary的长度都是小于4096的,如下图。

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第7张图片

那么问题出在哪里呢?还记得不记得我们第一篇到读取的什么结构现在还没用到?没错,就是MiniFAT。可能您想到了,DirectoryEntry中记录的SectorID不一定就是FAT的SectorID,还有可能是Mini-SectorID,这也就导致了实际上读取的内容与预期的不同。在Windows复合文件中有这样一个规定,就是凡是小于4096字节的内容,都要放置于Mini-Sector中,当然这个4096这个数也是存在于文件头Header中,我们可以在如下图的位置读取它,不过这个数是固定4096的。

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第8张图片

如同FAT一样,Mini-Sector的信息也是存放在Mini-FAT表中的,但是Sector是从文件头Header之后开始的,那么Mini-Sector是从哪里开始的呢?官方文档是这样说的,Mini-Sector所占的第一个Sector位置即Root Entry指向的SectorID,Mini-Sector总共的长度即Root Entry所记录的长度。我们可以通过刚才的FAT表获取所有Mini-Sector所占的Sector的顺序。

View Code

光有了Mini-Sector所占的Sector的顺序还不够,我们还需要知道Mini-Sector是怎样的顺序。这一点与FAT基本相同,固不在此赘述。

View Code

然后我们去写一个新的GetEntryOffset去满足不同的DirectoryEntry。

View Code

现在再试试,是不是所有的Office文档的DocumentSummary和Summary都能读取到了呢?

 

【三、PowerPoint Document的结构与解析】

跟Word不一样的是,WordDocument永远是Header后的第一个Sector,但是PowerPoint Document就不一定咯,不过PowerPoint不像Word那样,要想读取文字,还需要先读取WordDocument中的FIB以及TableStream中的数据才能读取文本,所有PowerPoint幻灯片的数据都存储在PowerPoint Document中。

简要说,PowerPoint中存储的内容是以Record为基础的,Record又包括Container Record和Atom Record两种,从名字其实就可以看出,前者是容器,后者是容器中的内容,那么其实PowerPoint Document中存储的其实也就是树形结构。

对于每一个Record,其结构如下:

  1. 从000H到001H的2字节UInt16,是Record的版本,其中低4位是recVer(特别的是,如果为0xF则一定为Container),高12位是recInstance。
  2. 从002H到003H的2字节UInt16,是Record的类型recType。
  3. 从004H到007H的4字节UInt32,是Record内容的长度recLen。
  4. 之后recLen字节是Record的具体内容。

接下来常见的recType的类型:

  1. 如果为0x03E8(1000),则为DocumentContainer。
  2. 如果为0x0FF0(4080),则为MasterListWithTextContainer或SlideListWithTextContainer或NotesListWithTextContainer。
  3. 如果为0x03F3(1011),则为MasterPersistAtom或SlidePersistAtom或NotesPersistAtom。
  4. 如果为0x0F9F(3999),则为TextHeaderAtom。
  5. 如果为0x03EA(1002),则为EndDocumentAtom。
  6. 如果为0x03F8(1016),则为MainMasterContainer。
  7. 如果为0x040C(1036),则为DrawingContainer。
  8. 如果为0x03EE(1006),则为SlideContainer。
  9. 如果为0x0FD9(4057),则为SlideHeadersFootersContainer或NotesHeadersFootersContainer。
  10. 如果为0x03EF(1007),则为SlideAtom。
  11. 如果为0x03F0(1008),则为NotesContainer。
  12. 如果为0x0FA0(4000),则为TextCharsAtom。
  13. 如果为0x0FA8(4008),则为TextBytesAtom。
  14. 如果为0x0FBA(4026),则为CString,储存很多文字的Atom。

由于PowerPoint支持上百种Record,这里只列举可能用到的一些Record,其他的就不一一列举了,详细内容可以参考微软文档“[MS-PPT].pdf”的2.13.24节。

为了更好地了解Record和PowerPoint Document,我们创建一个Record类

View Code

然后我们遍历所有节点读取Record的树形结构

View Code

结果类似于如下图所示

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第9张图片

其实如果要读取PowerPoint中所有的文本,那么只需要读取所有的TextCharsAtom、TextBytesAtom和CString就可以,需要说明的是,TextBytesAtom是以Ansi单字节进行存储的,而另外两个则是以Unicode形式存储的。上节我们已经读取过Word,那么接下来就不费劲了吧。

我们其实只要把读取到Atom时跳过内容的那句话“this.m_stream.Seek(record.RecordLength, SeekOrigin.Current);”替换为如下代码就可以了。

View Code

不过如果这样读取的话,也会把母版页及其他内容读取进来,比如下图:

Office文件的奥秘——.NET平台下不借助Office实现Word、Powerpoint等文件的解析_第10张图片

所以我们可以通过判断文字父Record的类型来决定是否读取这段文字。通常存放文字的Record有“ListWithTextContainer和HeadersFootersContainer”,我们仅需要判断文字Record的父Record是否是这俩就可以的。不过有一点,在用PowerPoint 2013存储的ppt文件,如果只判断这俩是读取不到内容的,还需要判断Type值为0xF00D的Record,不过这个RecordType在目前最新的文档中并没有说明。

这里把完整的代码贴出来:

View Code

最后附上这三篇文章全部的代码下载地址:http://files.cnblogs.com/mayswind/DotMaysWind.OfficeReader_3.rar

p.s.程序有多处偷小懒的情况,木哈哈。 

 

【四、相关链接】

1、Microsoft Open Specifications:http://www.microsoft.com/openspecifications/en/us/programs/osp/default.aspx
2、用PHP读取MS Word(.doc)中的文字:https://imethan.com/post-2009-10-06-17-59.html
3、Office檔案格式:http://www.programmer-club.com.tw/ShowSameTitleN/general/2681.html
4、LAOLA file system:http://stuff.mit.edu/afs/athena/astaff/project/mimeutils/share/laola/guide.html


你可能感兴趣的:(技术,Office,PowerPoint,Word,格式解析)