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

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

【題外話】

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

  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,是屬性組中屬性的個數。
  3. 從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相近就不再單獨給出了。

附,本文所有程式碼下載:https://github.com/mayswind/SimpleOfficeReader

 

【五、相關連結】

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

 

【後記】

花了好幾天的時間才寫完讀取DocumentSummaryInformation和SummaryInformation,果然自己寫程式用和寫成文章區別太大了,前者差不多就行,後者還得仔細查閱資料。如果您覺得好就點下推薦唄。