1. 程式人生 > >Office檔案的奧祕——.NET平臺下不借助Office實現Word、Powerpoint等檔案的解析

Office檔案的奧祕——.NET平臺下不借助Office實現Word、Powerpoint等檔案的解析

【題外話】

這是2010年參加比賽時候做的研究,當時為了實現對Word、Excel、PowerPoint檔案文字內容的抽取研究了很久,由於Java有POI庫,可以輕鬆的抽取各種Office文件,而.NET雖然有移植的NPOI,但是隻實現了最核心的Excel檔案的讀寫,所以之後查了很多資料才實現了Word和PowerPoint檔案文字的抽取。之後忙於各種事情一直沒時間整理,後來雖然想寫成文章但由於時間太久也記不清很多細節,現在重新查詢資料並整理如下,希望對大家有用。

【系列索引】 

【文章索引】

【一、.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的部分截圖及包含的資訊如下,比較重要的用粗體表示。

  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

【三、我們從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文件包含很多摘要資訊,比如標題、作者、編輯時間等等,如下圖。

摘要資訊又分為兩類,一類是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相近就不再單獨給出了。

【五、相關連結】

【題外話】

上篇文章很榮幸被NPOI的大神回覆了,同時也糾正了我一個問題,就是NPOI其實是有doc檔案的解析,只不過一直沒有跟隨正式版釋出過,要獲取這部分程式碼,可以移步CodePlex(http://npoi.codeplex.com/),訪問在SourceCode中的NPOI.ScratchPad中即可看到。給大家造成的不便在此表示抱歉。

【文章索引】

【一、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,官方是這麼描述的:

看上去這麼多,其實我們需要的僅是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”,否則換行將無法正常顯示,除此之外,還有其他的一些特殊字元也需要替換或處理。

【四、相關連結】


【題外話】

我突然發現現在做Office文件的解析要比2010年的時候容易得多,因為文件從2010年開始更新了好多好多次,讀起來也越來越容易。寫前兩篇文章的時候參考的好多還是微軟的舊文件(2010年的),寫這篇的時候重下了所有的文件,發現每個文件都好讀得多,整理得也更系統,感覺微軟真的是用心在做這個開放的事。當然,這些文件大部分也是2010年的時候才開始釋出出來的,仔細想想當年還是很幸運的。

【文章索引】

【一、奇怪的文件與FAT和DIFAT】

在剛開始做解析的時候,大都是從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:

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的,如下圖。

那麼問題出在哪裡呢?還記得不記得我們第一篇到讀取的什麼結構現在還沒用到?沒錯,就是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的順序。

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

結果類似於如下圖所示

其實如果要讀取PowerPoint中所有的文字,那麼只需要讀取所有的TextCharsAtom、TextBytesAtom和CString就可以,需要說明的是,TextBytesAtom是以Ansi單位元組進行儲存的,而另外兩個則是以Unicode形式儲存的。上節我們已經讀取過Word,那麼接下來就不費勁了吧。

我們其實只要把讀取到Atom時跳過內容的那句話“this.m_stream.Seek(record.RecordLength, SeekOrigin.Current);”替換為如下程式碼就可以了。

View Code

不過如果這樣讀取的話,也會把母版頁及其他內容讀取進來,比如下圖:

所以我們可以通過判斷文字父Record的型別來決定是否讀取這段文字。通常存放文字的Record有“ListWithTextContainer和HeadersFootersContainer”,我們僅需要判斷文字Record的父Record是否是這倆就可以的。不過有一點,在用PowerPoint 2013儲存的ppt檔案,如果只判斷這倆是讀取不到內容的,還需要判斷Type值為0xF00D的Record,不過這個RecordType在目前最新的文件中並沒有說明。

這裡把完整的程式碼貼出來:

View Code

p.s.程式有多處偷小懶的情況,木哈哈。 

【四、相關連結】