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文件文字的抽取。之后忙於各種事情一直沒時間整理,后來雖然想寫成文章但由於時間太久也記不清很多細節,現在重新查找資料 並整理如下,希望對大家有用。
【系列索引】
- Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(一)
獲取Office二進制文檔的DocumentSummaryInformation以及SummaryInformation - Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(二)
獲取Word二進制文檔(.doc)的文字內容(包括正文、頁眉、頁腳、批注等等) - Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(三)
詳細介紹Office二進制文檔中的存儲結構,以及獲取PowerPoint二進制文檔(.ppt)的文字內容 - Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(完)
介紹Office Open XML文檔(.docx、.pptx)如何進行解析以及解析Office文件常見開源類庫
Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(一)
獲取Office二進制文檔的DocumentSummaryInformation以及SummaryInformation
【文章索引】
- .NET下讀取Office文件的方式
- Windows復合二進制文件及其Header
- 我們從Directory開始
- DocumentSummaryInformation和SummaryInformation
- 相關鏈接
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文件結構非常復雜,這里只提供文件摘要信息和文件文本內容的解析。不過即使如 此,對於全文檢索什么的還是足夠的。
前幾年,微軟開放了一些私有格式的規范,使得所有人都可以對其文件進行解析,而不需要支付任何費用,這也使得我們編寫解析文件的程序成為可能,相關鏈接在 文章最后可以找到。對於一個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的部分截圖及包含的信息如下,比較重要的用粗體表示。
- Header的前8字節Byte[],也就是整個文件的前8字節,都是固定的0xD0 0xCF 0x11 0xE0 0xA1 0xB1 0x1A 0xE1,如果不是則說明不是復合文件。
- 從008H到017H的16字節,是Class Id,不過很多文件都置的0。
- 從018H到019H的2字節UInt16,是文件格式的次要版本。
- 從01AH到01BH的2字節UInt16,是文件格式的主要版本。
- 從01CH到01DH的2字節UInt16,是固定為0xFE 0xFF,表示文檔使用的是Little Endian(低位在前,高位在后)。
- 從01EH到01FH的2字節UInt16,是Sector大小的冪,默認為9(0x09 0x00),即每個Sector為512字節。
- 從020H到021H的2字節UInt16,是Mini-Sector大小的冪,默認為6(0x06 0x00),即每個Mini-Sector為64字節。
- 從022H到023H的2字節UInt16,是預留的,必須置0。
- 從024H到027H的4字節UInt32,是預留的,必須置0。
- 從028H到02BH的4字節UInt32,是預留的,必須置0。
- 從02CH到02FH的4字節UInt32,是FAT的數量。
- 從030H到033H的4字節UInt32,是Directory開始的SectorID。
- 從034H到037H的4字節UInt32,是用於事務的,必須置0。
- 從038H到03BH的4字節UInt32,是最小串(Stream)的最大大小,默認為4096(0x00 0x10 0x00 0x10)。
- 從03CH到03FH的4字節UInt32,是MiniFAT表開始的SectorID。
- 從040H到043H的4字節UInt32,是MiniFAT表的數量。
- 從044H到047H的4字節UInt32,是DIFAT開始的SectorID。
- 從048H到04BH的4字節UInt32,是DIFAT的數量。
- 從04CH到1FFH的436字節UInt32[],是前109塊FAT表的SectorID。
那么我們可以寫如下的代碼將Header中重要的內容解析出來。

說個比較有意思的,.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
復合文檔中其實存放着很多內容,這么多內容需要有個目錄,那么Directory就是這個目錄。從Header中我們可以讀取出Directory開始的 SectorID,我們可以Seek到這個位置(0x200 + sectorSize * dirStartSectorID)。Directory中每個DirectoryEntry固定為128字節,其主要結構如下:
- 從000H到040H的64字節,是存儲DirectoryEntry名稱的,並且是以Unicode存儲的,即每個字符占2個字節,其實可以看做是UInt16。
- 從041H到042H的2字節UInt16,是DirectoryEntry名稱的長度(包括最后的“\0”)。
- 從042H到042H的1字節Byte,是DirectoryEntry的類型。(主要的有:1為目錄,2為節點,5為根節點)
- 從044H到047H的4字節UInt32,是該DirectoryEntry左兄弟的EntryID(第一個DirectoryEntry的EntryID為0,下同)。
- 從048H到04BH的4字節UInt32,是該DirectoryEntry右兄弟的EntryID。
- 從04CH到04FH的4字節UInt32,是該DirectoryEntry一個孩子的EntryID。
- 從074H到077H的4字節UInt32,是該DirectoryEntry開始的SectorID。
- 從078H到07BH的4字節UInt32,是該DirectoryEntry存儲的所有字節長度。
顯然,Directory其實是一個樹形的結構,我們只要從第一個Entry(Root Entry)開始遞歸搜索就可以了。
為了方便開發,我們創建一個DirectoryEntry的類

然后我們遞歸搜索就可以了

【四、DocumentSummaryInformation和SummaryInformation】
Office文檔包含很多摘要信息,比如標題、作者、編輯時間等等,如下圖。
摘要信息又分為兩類,一類是DocumentSummaryInformation,另一類是SummaryInformation,分別包含不同種類的 摘要信息。通過上述的代碼應該能獲取到Root Entry下有一個叫“\005DocumentSummaryInformation”的Entry和一個叫“ \005SummaryInformation”的Entry。
對於DocumentSummaryInformation,其結構如下
- 從018H到01BH的4字節UInt32,是存儲屬性組的個數。
- 從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的偏移。
對於每個屬性組,其結構如下:
- 從000H到003H的4字節UInt32,是屬性組大小。
- 從004H到007H的4字節UInt32,是屬性組中屬性的個數。從008H開始的每8字節,是屬性的信息:
- 對於前4字節UInt32,是屬性編號,表示屬性的種類。
- 對於后4字節UInt32,是屬性內容相對於屬性組的偏移。
常見的屬性編號有以下這些:

對於每個屬性,其結構如下:
- 從000H到003H的4字節UInt32,是屬性內容的類型。
- 類型為0x02時為UInt16。
- 類型為0x03時為UInt32。
- 類型為0x0B時為Boolean。
- 類型為0x1E時為String。
- 剩余的字節為屬性的內容。
- 除了類型是String時為不定長,其余三種均為4位字節(多余字節置0)。
- 類型是String時前4字節是字符串的長度(包括“\0”),所以沒法使用BinaryReader的ReadString讀取。之后長度為字符串內容,字符串是使用單字節編碼進行存儲的,可以使用Encoding中的GetString獲取字符串內容。
為了方便開發,我們創建一個DocumentSummary的類。比較有意思的是,不論DocumentSummaryInformation還是 SummaryInformation,第一個屬性都是記錄該組內容的代碼頁編碼,可以通過Encoding.GetEncoding()獲取對應的編碼 然后用GetString把對應的字符串解析出來:

然后我們進行讀取就可以了:

而SummaryInformation與DocumentSummaryInformation相比讀取方式是一樣的,只不過屬性組的16位標識為 0xE0 0x85 0x9F 0xF2 0xF9 0x4F 0x68 0x10 0xAB 0x91 0x08 0x00 0x2B 0x27 0xB3 0xD9。
常見的SummaryInformation屬性的屬性編號如下:

其他代碼由於與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
Office文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(二)
獲取Word二進制文檔(.doc)的文字內容(包括正文、頁眉、頁腳、批注等等)
【題外話】
上篇文章很榮幸被NPOI的大神回復了,同時也糾正了我一個問題,就是NPOI其實是有doc文件的解析,只不過一直沒有跟隨正式版發布過,要獲取這部分代碼,可以移步CodePlex(http://npoi.codeplex.com/),訪問在SourceCode中的NPOI.ScratchPad中即可看到。給大家造成的不便在此表示抱歉。
【文章索引】
我們接着第一篇的代碼繼續,不知大家有沒有查看過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:
- 從000H到001H的2字節UInt16,是固定為0xA5EC,表明文檔為Word二進制文件。
- 從002H到003H的2字節UInt16,是Word格式的版本(nFib),但實際上這里一般為0xC1,即Word97的格式,真實的版本在之后會出現。
- 從 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;文檔不加密時忽略該屬性。
- 從00CH到00DH的2字節UInt16,為固定的0x00BF或0x00C1(某些語言的Word97會為0x00C1)
- 從00EH到011H的4字節UInt32,當文檔加密並且混淆,則為混淆的密鑰;如果加密不混淆,則為加密頭的長度;否則應置0。
- 從012H到012H的1字節Byte,應當置0,並且忽略。
- 從013H到013H的1字節Byte,被划分為6部分,除了第6部分占3bit之外,其余各占1bit。
- 第1位,必須置0,並且忽略。
- 第2位,通過右鍵菜單->新建->新建Word文件創建的空文件為1,其余應當為0。
- 第3位,為是否要用應用程序的默認值覆蓋頁面中的頁面大小、頁面方向、頁邊距等。
- 第4位和第5位,未定義,應當忽略。
- 第6-8位,未定義,應當忽略。
- 從014H到015H和016H到017H的各2字節,應當置0,並且忽略。
- 從018H到01BH和01CH到01FH的各4字節,未定義,應當忽略。
那FibBase之后呢?其實FIB包含很多的內容,從FibBase開始按順序分別是:
- 2字節的UInt16,為之后FibRgW97塊中16位整數的個數,固定為0x000E。
- 28字節的FibRgW97塊,包含14個UInt16。
- 2字節的UInt16,為之后FibRgLw97塊中32位整數的個數,固定為0x0016。
- 88字節的FibRgLw97塊,包含22個UInt32。
- 2字節的UInt16,為之后FibRgFcLcb塊中64位整數的個數(但FibRgFcLcb實際存儲的是32位整數)。
- 如果文檔為Word97,該項為0x005D。
- 如果文檔為Word2000,該項為0x006C。
- 如果文檔為Word2002,該項為0x0088。
- 如果文檔為Word2003,該項為0x00A4。
- 如果文檔為Word2007,該項為0x00B7。
- 不定長的FibRgFcLcb塊,包含不定個數的32位UInt32(數量也就是上述個數的2倍),但可見至少擁有186個。
- 2字節的UInt16,為之后FibRgCswNew塊中16位整數的個數。
- 如果文檔為Word97,該項為0x00(實際上不包含FibRgCswNew)。
- 如果文檔為Word2000-2003,該項為0x02。
- 如果文檔為Word2007,該項為0x05。
- 不定長的FibRgCswNew塊,首先是固定長度的UInt16即Word文檔的真實版本nFibNew,然后一個UInt16表示文檔在完整存檔后快速存檔的次數,之后如果是Word2007則還有3個UInt16文檔說沒有定義且要求忽略(大囧)。
看完FIB結構后我們先來看下nFib與文件版本對應的情況:
- 0x00C1(nFib)表示文件為Word97(或者為更高版本的文檔)。
- 0x00D9(nFibNew)表示文件為Word2000。
- 0x0101(nFibNew)表示文件為Word2002。
- 0x010C(nFibNew)表示文件為Word2003。
- 0x0112(nFibNew)表示文件為Word2007。
由於FIB中內容實在太多了,之后的部分就不再介紹了,不過為了讀取文檔的內容我們還應該看看如下的內容(當然也不一定都用到)。
- FibRgW97中的14個UInt16,為文檔的語言(lidFE),比如0x0804為簡體中文。如果文檔是Unicode存儲的當然無所謂,如果是ANSI碼存儲的那么就需要獲取這個了。
- FibRgLw97中的第1個Int32,為Word Document中有意義的字節數(即Word Document之后的字節數都可以忽略)。
- FibRgLw97中的第4個Int32,為文檔中正文(Main document)的總字數。
- FibRgLw97中的第5個Int32,為文檔中頁腳(Footnote subdocument)的總字數。
- FibRgLw97中的第6個Int32,為文檔中頁眉(Header subdocument)的總字數。
- FibRgLw97中的第7個Int32,為文檔中批注(Comment subdocument)的總字數。
- FibRgLw97中的第8個Int32,為文檔中尾注(Endnote subdocument)的總字數。
- FibRgLw97中的第10個Int32,為文檔中文本框(Textbox subdocument)的總字數。
- FibRgLw97中的第11個Int32,為文檔中頁眉文本框(Textbox Subdocument of the header)的總字數。
- FibRgFcLcb中的第67個UInt32,為Piece Table在Table Stream中的偏移(fcClx)。
- FibRgFcLcb中的第68個UInt32,為Piece Table的字節數(lcbClx)。
以上這些信息我們可以編寫如下代碼獲取:

Table Stream其實就是1Table或者0Table的總稱,具體文字存在那個Table中還要根據FIB中的信息。由於復合文件是以一個個Sector形 式存儲的,所以我們首先需要獲取文字存儲在哪些個Sector中。實際上,文本的存儲是由Piece Element(暫且這么叫吧)控制着,包括是否啟用Unicode、每塊的位置等等,這些內容都存放於Table Stream中的Piece Table中,Piece Table相對Table Stream的偏移量可以從FIB中獲取到。
關於Piece Element,官方是這么描述的:
看上去這么多,其實我們需要的僅是fc中定義的是否使用Unicode存儲文本(fc中 第31位為0則為Unicode,為1則為Ansi),以及文本相對於WordDocument的偏移量(fc中低位30位),我們首先對Piece Element定義一個類,可以看出,一個Piece Element的大小實際為2 + 4 + 2 = 8字節:

然后我們來看Piece Table,其結構為:
- 從000H到000H的1字節Byte,是Piece Table的標識,為固定的0x02。
- 從001H到004H的4字節UInt32,是Piece Table的大小(即存儲文字的Sector的數量)。
官方給了一個Piece Table中個數的計算公式
- 之后4*(n + 1)個字節,是每個Piece Element存儲的文本的開始位置(結束位置即下一個的開始位置)。
- 之后8*n個字節,是每個Piece Element的相關信息。
Piece Table信息我們可以編寫如下代碼獲取:

上頭我們可以獲取到Word中文本的開始和結束位置,其實一個Word文檔中,文字是按如下順序存儲的:
- 正文內容(Main document)
- 頁腳(Footnote subdocument)
- 頁眉(Header subdocument)
- 批注(Comment subdocument)
- 尾注(Endnote subdocument)
- 文本框(Textbox subdocument)
- 頁眉文本框(Textbox Subdocument of the header)
所以,我們可以根據FibRgLw97中獲取的每一部分的字數以及Piece Table中起始的位置來獲取每一部分的文字。
比如正文內容的位置為[0, ccpText],頁腳的位置為[ccpText + 1, ccpText + 1 + ccpFtn]……
所以我們編寫如下代碼獲取:

不過需要注意的是,由於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文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(三)
詳細介紹Office二進制文檔中的存儲結構,以及獲取PowerPoint二進制文檔(.ppt)的文字內容
【題外話】
我突然發現現在做Office文檔的解析要比2010年的時候容易得多,因為文檔從2010年開始更新了好多好多次,讀起來也越來越容易。寫前兩篇文章的 時候參考的好多還是微軟的舊文檔(2010年的),寫這篇的時候重下了所有的文檔,發現每個文檔都好讀得多,整理得也更系統,感覺微軟真的是用心在做這個 開放的事。當然,這些文檔大部分也是2010年的時候才開始發布出來的,仔細想想當年還是很幸運的。
【文章索引】
在剛開始做解析的時候,大都是從Word文檔(.doc)入手,而doc文檔沒有太多復 雜的東西,所以按照流程都可以輕松做到,也不會出現什么差錯。但是做PowerPoint解析的時候就會遇到很多問題,比如如果按第一節講的進行解析 Directory的話會發現,很多PowerPoint文檔是沒有DocumentSummaryInformation的,這還不是關鍵,關鍵是,還 有一部分甚至連PowerPoint Document都沒有,見下圖。
其實這種問題不光解析PowerPoint的時候會遇到,解析Excel的時候同樣會遇到,那么這到底是什么問題呢?其實我們在讀取Directory時,認為Directory所在的Sector是按EntryID從小到大排列的,但實際上DirectoryEntry並不一定是這樣的,並且有的Entry所在的Sector有可能在RootEntry之前。
不知大家是否還記得FAT和DIFAT這兩個結構,雖然從第一篇就讀取了諸如開始的位置和個數,但是一直沒有使用,那么本篇先詳細介紹一下這倆結構。
首先來看下微軟的文檔是如何描述這倆結構的:
我們可以看到,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:

需要說明的是,這里並沒有判斷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。

文章到這,大家應該能明白接下來做什么了吧?之前由於“理所當然”地認為Sector的順序就是存儲的順序,所以導致很多DirectoryEntry無法讀取出來。所以現在我們應該首先獲取DirectoryEntry所占Sector的真實順序。

然后獲取每個DirectoryEntry偏移的方法也應該改為:

這樣所有的DirectoryEntry就都能獲取到了。
【二、奇怪的DocumentSummary和Summary】
在能真正獲取所有的DirectoryEntry之后,不知道大家發現了沒有,很多文檔 的DocumentSummary和Summary卻還是無法獲取到的,一般說來就是得到SectorID后Seek到指定位置后讀到的數據跟預期的有太 大的不同。不過有個很有意思的事就是,這些無法讀取的DocumentSummary和Summary的長度都是小於4096的,如下圖。
那么問題出在哪里呢?還記得不記得我們第一篇到讀取的什么結構現在還沒用到?沒錯,就是 MiniFAT。可能您想到了,DirectoryEntry中記錄的SectorID不一定就是FAT的SectorID,還有可能是Mini- SectorID,這也就導致了實際上讀取的內容與預期的不同。在Windows復合文件中有這樣一個規定,就是凡是小於4096字節的內容,都要放置於 Mini-Sector中,當然這個4096這個數也是存在於文件頭Header中,我們可以在如下圖的位置讀取它,不過這個數是固定4096的。
如同FAT一樣,Mini-Sector的信息也是存放在Mini-FAT表中的,但是 Sector是從文件頭Header之后開始的,那么Mini-Sector是從哪里開始的呢?官方文檔是這樣說的,Mini-Sector所占的第一個 Sector位置即Root Entry指向的SectorID,Mini-Sector總共的長度即Root Entry所記錄的長度。我們可以通過剛才的FAT表獲取所有Mini-Sector所占的Sector的順序。

光有了Mini-Sector所占的Sector的順序還不夠,我們還需要知道Mini-Sector是怎樣的順序。這一點與FAT基本相同,固不在此贅述。

然后我們去寫一個新的GetEntryOffset去滿足不同的DirectoryEntry。

現在再試試,是不是所有的Office文檔的DocumentSummary和Summary都能讀取到了呢?
跟Word不一樣的是,WordDocument永遠是Header后的第一個 Sector,但是PowerPoint Document就不一定咯,不過PowerPoint不像Word那樣,要想讀取文字,還需要先讀取WordDocument中的FIB以及 TableStream中的數據才能讀取文本,所有PowerPoint幻燈片的數據都存儲在PowerPoint Document中。
簡要說,PowerPoint中存儲的內容是以Record為基礎的,Record又包 括Container Record和Atom Record兩種,從名字其實就可以看出,前者是容器,后者是容器中的內容,那么其實PowerPoint Document中存儲的其實也就是樹形結構。
對於每一個Record,其結構如下:
- 從000H到001H的2字節UInt16,是Record的版本,其中低4位是recVer(特別的是,如果為0xF則一定為Container),高12位是recInstance。
- 從002H到003H的2字節UInt16,是Record的類型recType。
- 從004H到007H的4字節UInt32,是Record內容的長度recLen。
- 之后recLen字節是Record的具體內容。
接下來常見的recType的類型:
- 如果為0x03E8(1000),則為DocumentContainer。
- 如果為0x0FF0(4080),則為MasterListWithTextContainer或SlideListWithTextContainer或NotesListWithTextContainer。
- 如果為0x03F3(1011),則為MasterPersistAtom或SlidePersistAtom或NotesPersistAtom。
- 如果為0x0F9F(3999),則為TextHeaderAtom。
- 如果為0x03EA(1002),則為EndDocumentAtom。
- 如果為0x03F8(1016),則為MainMasterContainer。
- 如果為0x040C(1036),則為DrawingContainer。
- 如果為0x03EE(1006),則為SlideContainer。
- 如果為0x0FD9(4057),則為SlideHeadersFootersContainer或NotesHeadersFootersContainer。
- 如果為0x03EF(1007),則為SlideAtom。
- 如果為0x03F0(1008),則為NotesContainer。
- 如果為0x0FA0(4000),則為TextCharsAtom。
- 如果為0x0FA8(4008),則為TextBytesAtom。
- 如果為0x0FBA(4026),則為CString,儲存很多文字的Atom。
由於PowerPoint支持上百種Record,這里只列舉可能用到的一些Record,其他的就不一一列舉了,詳細內容可以參考微軟文檔“[MS-PPT].pdf”的2.13.24節。
為了更好地了解Record和PowerPoint Document,我們創建一個Record類

然后我們遍歷所有節點讀取Record的樹形結構

結果類似於如下圖所示
其實如果要讀取PowerPoint中所有的文本,那么只需要讀取所有的 TextCharsAtom、TextBytesAtom和CString就可以,需要說明的是,TextBytesAtom是以Ansi單字節進行存儲 的,而另外兩個則是以Unicode形式存儲的。上節我們已經讀取過Word,那么接下來就不費勁了吧。
我們其實只要把讀取到Atom時跳過內容的那句話“this.m_stream.Seek(record.RecordLength, SeekOrigin.Current);”替換為如下代碼就可以了。

不過如果這樣讀取的話,也會把母版頁及其他內容讀取進來,比如下圖:
所以我們可以通過判斷文字父Record的類型來決定是否讀取這段文字。通常存放文字的 Record有“ListWithTextContainer和HeadersFootersContainer”,我們僅需要判斷文字Record的父 Record是否是這倆就可以的。不過有一點,在用PowerPoint 2013存儲的ppt文件,如果只判斷這倆是讀取不到內容的,還需要判斷Type值為0xF00D的Record,不過這個RecordType在目前最 新的文檔中並沒有說明。
這里把完整的代碼貼出來:

最后附上這三篇文章全部的代碼下載地址: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文件的奧秘——.NET平台下不借助Office實現Word、Powerpoint等文件的解析(完)
介紹Office Open XML文檔(.docx、.pptx)如何進行解析以及解析Office文件常見開源類庫
【題外話】
這是這個系列的最后一篇文章了,為了不讓自己覺得少點什么,順便讓自己感覺完美一些,就再把OOXML說一下吧。不過說實話,OOXML真的太容易 解析了,而且這方面的文檔包括成熟的開源類庫也特別特別特別的多,所以我就稍微說一下,文章中引用了不少的鏈接,感興趣的話可以深入了解下。
【文章索引】
- 初見Office Open XML(OOXML)
- OOXML文檔屬性的解析
- Word 2007文件的解析
- PowerPoint 2007文件的解析
- 常見Office文檔(Word、PowerPoint、Excel)文件的開源類庫
- 相關鏈接
先來看一段微軟官方對Office Open XML的說明(詳細見http://office.microsoft.com/zh-cn/support/HA010205815.aspx?CTT=3):
可以看到,與Windows 復合文檔不同的是,OOXML生來就是開放的,而且由於基於zip+xml的格式,使得讀取變得更容易,如果僅是為了抽取文字,我們甚至不需要讀取文檔的任何參數!
如果您之前不了解OOXML的話,我們可以把手頭docx、pptx以及xlsx文件的擴展名改為zip,然后用壓縮軟件打開看看。
打開的這三個文件分別是docx、pptx和xlsx,我們可以看到,目錄結構清晰可見,所以我們只需要使用讀取zip的類庫讀取zip文件,然后 再解析xml文件即可。對於使用.NET Framework 3.0及以上的,可以直接使用.NET自帶的Package類(System.IO.Packaging,在WindowsBase.dll中)進行解 壓,個人感覺如果只是讀取zip流中的文件流或內容,WindowsBase中的Package還是很好用的。如果用於.NET CF或者2.0甚至以下的CLR可以使用SharpZipLib(支持CLR 1.1、2.0、4.0,官方網站http://www.icsharpcode.net/),也可以使用DotNetZip(支持CLR 2.0,官方網站http://dotnetzip.codeplex.com/),個人感覺后者的License更友好些。
比如我們使用自帶的Package打開OOXML文件:

OOXML文件的文檔屬性其實存在於docProps目錄下,比較重要的有三個文件
- app.xml:記錄文檔的屬性,內容類似之前的DocumentSummaryInformation。
- core.xml:記錄文檔核心的屬性,比如創建時間、最后修改時間等等,內容類似之前的SummaryInformation。
- thumbnail.*:文檔的縮略圖,不同文件存儲的是不同的格式,比如Word為emf,Excel為wmf,PowerPoint為jpeg。
我們只需要遍歷XML文件中所有的子節點就可以讀出所有的屬性,為了好看,這里還用的Windows復合文件中的名稱:

Word文件(.docx)主要的內容基本都存在於word目錄下,比較重要的有以下的內容
- document.xml:記錄Word文檔的正文內容
- footer*.xml:記錄Word文檔的頁腳
- header*.xml:記錄Word文檔的頁眉
- comments.xml:記錄Word文檔的批注
- footnotes.xml:記錄Word文檔的腳注
- endnotes.xml:記錄Word文檔的尾注
這里我們只讀取Word文檔的正文內容,由於OOXML文檔在存儲文字時也是嵌套結構存儲的,比如對於Word而 言,<w:p></w:p>之間存儲的是段落,段落中會嵌套着<w:t></w:t>,而這個存儲的是 文字。除此之外<w:tab/>是Tab符號,<w:br w:type="page"/>是分頁符等等,所以我們需要寫一個方法遞歸處理這些標簽:

然后我們從根標簽開始讀取就可以了

PowerPoint文件(.pptx)主要的內容都存在於ppt目錄下,而幻燈片的信息則又在slides子目錄下,這里邊幻燈片按照slide + 頁序號 +.xml的名稱進行存儲,我們挨個順序讀取就可以。不過需要注意的是,由於字符串比較的問題,如 “slide10.xml”<"slide2.xml",所以如果你按順序讀取的話可能會出現頁碼錯亂的情況,所以我們可以先進行排序然后再挨個頁 面從根標簽讀取就可以了。

附,本系列全部代碼下載:https://github.com/mayswind/SimpleOfficeReader
【五、常見Office文檔(Word、PowerPoint、Excel)文件的開源類庫】
1、NPOI:http://npoi.codeplex.com
這個沒的說,.NET上最好的,沒有之一,Office文檔類庫,提供完整的Excel讀取與編輯操作,目前支持二進制(.xls)文件和 OOXML(.xlsx)兩種格式。如果用過Apache的Java類庫POI的話,NPOI提供幾乎一樣的類庫。實際上,對於ASP.NET,需要編輯 的Office文檔大多都是Excel文件,或者也可以使用Excel文件代替,所以使用NPOI幾乎已經能滿足所有需要。目前已經支持docx文件,而 doc的支持則在NPOI.ScratchPad中,大家可以去Source Code中下載自己編譯。如果不需要OOXML的話,類庫僅有1.5MB,並且支持.NET CLR 2.0和4.0。
2、Open XML SDK 2.0 for Microsoft Office:http://msdn.microsoft.com/en-us/library/bb448854(office.14).aspx
微軟提供的Open XML SDK,支持讀寫任意OOXML文檔,其同時提供了一個工具,可以打開Office文檔然后直接生成使用該類庫生成該文檔的程序代碼。只不過類庫確實大了些,有5MB之多,並且需要.NET Framework 3.5的支持。
3、Office Binary Translator to Open XML:http://b2xtranslator.sourceforge.net/
這是我最近才知道的一個類庫,其實很早很早以前就有了,其可以將Windows復合文檔(.doc、.ppt、.xls)轉換為對應的OOXML格 式(.docx、.pptx、.xlsx),當然你也可以獲取文件中存儲的內容。不知道為什么,這個網站被牆了。如果你想研究Windows復合文檔的 話,我比較推薦這個類庫,因為NPOI實在是太完美的一個類庫,要想走一遍文件讀取的流程實在是太復雜,但是如果用這個類庫單步的話還是很容易懂的。這個 類庫將每種文件的支持(以及支持的模塊等)都拆分到了不同的項目中,支持每種文件僅需要幾百KB,而且是基於.NET CLR 2.0的。
4、EPPlus:http://epplus.codeplex.com
在2010年NPOI還不支持OOXML的時候,個人感覺EPPlus是最好的.xlsx文件處理的類庫,其僅有幾百KB,非常輕量,對於zip文 件的讀取,這個類庫沒有選擇SharpZipLib或者DotNetZip,老版本需要.NET Framework 3.0就行,剛看了下新版本得需要.NET Framework 3.5才可以。
5、ExcelDataReader:http://exceldatareader.codeplex.com
也是一個非常輕量並且好用的庫,同時支持讀取.xls和.xlsx,當年在使用EPPlus之前使用的這個類庫,記不得是因為什么問題替換成了 EPPlus,也不知道這個問題現在解決了沒有。這個類庫的好處是僅需要.NET CLR 2.0,並且支持.NET CF,只不過現在已經不需要開發Windows Mobile的應用了。
1、OpenXMLDeveloper.org:http://openxmldeveloper.org
2、如何:從 Office Open XML 文檔檢索段落:http://msdn.microsoft.com/zh-cn/library/bb669175.aspx
3、如何操作 Office Open XML 格式文檔:http://www.microsoft.com/china/msdn/library/office/office/howManipulateOfficexml.mspx
4、如何實現...(打開 XML SDK):http://msdn.microsoft.com/zh-cn/library/bb491088.aspx
【后記】
終於到了最后一篇,這個系列就到這結束了,感謝大家的捧場,我也終於實現了兩年前的心願。說實話,我確實沒想到第一篇會有那么多的訪問和推薦,因為 需要解析Office文檔的畢竟是少數的。寫這四篇文章也希望起到拋磚引玉的作用,起碼可以對Office文檔有個最基礎的了解,而之后如果想深入了解下 去也會容易得多,這也是我要把這些內容寫出來的原因。
【補遺】
在寫完這四篇文章后,我偶然發現微軟關於這方面竟然有中文文檔,淚奔了,為什么之前我沒有找到。所以在此附上幾篇常用的鏈接。
1、了解 Office 二進制文件格式:http://msdn.microsoft.com/zh-cn/library/gg615407(v=office.14).aspx
2、了解 Word MS-DOC 二進制文件格式:http://msdn.microsoft.com/zh-CN/library/gg615596
3、了解 PowerPoint MS-PPT 二進制文件格式:http://msdn.microsoft.com/zh-CN/library/gg615594
4、了解采用 Office 二進制文件格式的圖形:http://msdn.microsoft.com/zh-CN/library/gg985447
5、在二進制 PowerPoint MS-PPT 文件中查找圖形:http://msdn.microsoft.com/zh-CN/library/hh244173