【題外話】
上篇文章很榮幸被NPOI的大神回復了,同時也糾正了我一個問題,就是NPOI其實是有doc文件的解析,只不過一直沒有跟隨正式版發布過,要獲取這部分代碼,可以移步CodePlex(http://npoi.codeplex.com/),訪問在SourceCode中的NPOI.ScratchPad中即可看到。給大家造成的不便在此表示抱歉。
【系列索引】
- 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文件常見開源類庫
【文章索引】
我們接着第一篇的代碼繼續,不知大家有沒有查看過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)。
以上這些信息我們可以編寫如下代碼獲取:

1 #region 字段 2 private UInt16 m_nFib; 3 private Boolean m_isComplexFile; 4 private Boolean m_hasPictures; 5 private Boolean m_isEncrypted; 6 private Boolean m_is1Table; 7 8 private UInt16 m_lidFE; 9 10 private Int32 m_cbMac; 11 private Int32 m_ccpText; 12 private Int32 m_ccpFtn; 13 private Int32 m_ccpHdd; 14 private Int32 m_ccpAtn; 15 private Int32 m_ccpEdn; 16 private Int32 m_ccpTxbx; 17 private Int32 m_ccpHdrTxbx; 18 19 private UInt32 m_fcClx; 20 private UInt32 m_lcbClx; 21 #endregion 22 23 #region 讀取WordDocument 24 private void ReadWordDocument() 25 { 26 DirectoryEntry entry = this.m_dirRootEntry.GetChild("WordDocument"); 27 28 if (entry == null) 29 { 30 return; 31 } 32 33 Int64 entryStart = this.GetSectorOffset(entry.SectorID); 34 this.m_stream.Seek(entryStart, SeekOrigin.Begin); 35 36 this.ReadFileInformationBlock(); 37 } 38 39 #region 讀取FileInformationBlock 40 private void ReadFileInformationBlock() 41 { 42 this.ReadFibBase(); 43 this.ReadFibRgW97(); 44 this.ReadFibRgLw97(); 45 this.ReadFibRgFcLcb(); 46 this.ReadFibRgCswNew(); 47 } 48 49 #region FibBase 50 private void ReadFibBase() 51 { 52 UInt16 wIdent = this.m_reader.ReadUInt16(); 53 if (wIdent != 0xA5EC) 54 { 55 throw new Exception("該文件不是Word文件!"); 56 } 57 58 this.m_nFib = this.m_reader.ReadUInt16(); 59 this.m_reader.ReadUInt16();//unused 60 this.m_reader.ReadUInt16();//lid 61 this.m_reader.ReadUInt16();//pnNext 62 63 UInt16 flags = this.m_reader.ReadUInt16(); 64 this.m_isComplexFile = this.GetBitFromInteger(flags, 2); 65 this.m_hasPictures = this.GetBitFromInteger(flags, 3); 66 this.m_isEncrypted = this.GetBitFromInteger(flags, 8); 67 this.m_is1Table = this.GetBitFromInteger(flags, 9); 68 69 if (this.m_isComplexFile) 70 { 71 throw new Exception("不支持復雜文件的讀取!"); 72 } 73 74 if (this.m_isEncrypted) 75 { 76 throw new Exception("不支持加密文件的讀取!"); 77 } 78 79 this.m_stream.Seek(32 - 12, SeekOrigin.Current); 80 } 81 #endregion 82 83 #region FibRgW97 84 private void ReadFibRgW97() 85 { 86 UInt16 count = this.m_reader.ReadUInt16(); 87 88 if (count != 0x000E) 89 { 90 throw new Exception("FibRgW97長度錯誤!"); 91 } 92 93 this.m_stream.Seek(26, SeekOrigin.Current); 94 this.m_lidFE = this.m_reader.ReadUInt16(); 95 } 96 #endregion 97 98 #region FibRgLw97 99 private void ReadFibRgLw97() 100 { 101 UInt16 count = this.m_reader.ReadUInt16(); 102 103 if (count != 0x0016) 104 { 105 throw new Exception("FibRgLw97長度錯誤!"); 106 } 107 108 this.m_cbMac = this.m_reader.ReadInt32(); 109 this.m_reader.ReadInt32();//reserved1 110 this.m_reader.ReadInt32();//reserved2 111 this.m_ccpText = this.m_reader.ReadInt32(); 112 this.m_ccpFtn = this.m_reader.ReadInt32(); 113 this.m_ccpHdd = this.m_reader.ReadInt32(); 114 this.m_reader.ReadInt32();//reserved3 115 this.m_ccpAtn = this.m_reader.ReadInt32(); 116 this.m_ccpEdn = this.m_reader.ReadInt32(); 117 this.m_ccpTxbx = this.m_reader.ReadInt32(); 118 this.m_ccpHdrTxbx = this.m_reader.ReadInt32(); 119 120 this.m_stream.Seek(44, SeekOrigin.Current); 121 } 122 #endregion 123 124 #region FibRgFcLcb 125 private void ReadFibRgFcLcb() 126 { 127 UInt16 count = this.m_reader.ReadUInt16(); 128 this.m_stream.Seek(66 * 4, SeekOrigin.Current); 129 130 this.m_fcClx = this.m_reader.ReadUInt32(); 131 this.m_lcbClx = this.m_reader.ReadUInt32(); 132 133 this.m_stream.Seek((count * 2 - 68) * 4, SeekOrigin.Current); 134 } 135 #endregion 136 137 #region FibRgCswNew 138 private void ReadFibRgCswNew() 139 { 140 UInt16 count = this.m_reader.ReadUInt16(); 141 this.m_nFib = this.m_reader.ReadUInt16(); 142 this.m_stream.Seek((count - 1) * 2, SeekOrigin.Current); 143 } 144 #endregion 145 #endregion 146 #endregion 147 148 private Boolean GetBitFromInteger(Int32 integer, Int32 bitIndex) 149 { 150 Int32 num = (Int32)Math.Pow(2, bitIndex); 151 return (integer & num) == num; 152 }
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字節:

1 public class PieceElement 2 { 3 #region 字段 4 private UInt16 m_info; 5 private UInt32 m_fc; 6 private UInt16 m_prm; 7 private Boolean m_isUnicode; 8 #endregion 9 10 #region 屬性 11 /// <summary> 12 /// 獲取是否以Unicode形式存儲文本 13 /// </summary> 14 public Boolean IsUnicode 15 { 16 get { return this.m_isUnicode; } 17 } 18 19 /// <summary> 20 /// 獲取文本偏移量 21 /// </summary> 22 public UInt32 Offset 23 { 24 get { return this.m_fc; } 25 } 26 #endregion 27 28 #region 構造函數 29 public PieceElement(UInt16 info, UInt32 fcCompressed, UInt16 prm) 30 { 31 this.m_info = info; 32 this.m_fc = fcCompressed & 0x3FFFFFFF;//后30位 33 this.m_prm = prm; 34 this.m_isUnicode = (fcCompressed & 0x40000000) == 0;//第31位 35 36 if (!this.m_isUnicode) this.m_fc = this.m_fc / 2; 37 } 38 #endregion 39 }
然后我們來看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信息我們可以編寫如下代碼獲取:

1 private void ReadTableStream() 2 { 3 DirectoryEntry entry = this.m_dirRootEntry.GetChild((this.m_is1Table ? "1Table" : "0Table")); 4 5 if (entry == null) 6 { 7 return; 8 } 9 10 Int64 pieceTableStart = this.GetSectorOffset(entry.SectorID) + this.m_fcClx; 11 Int64 pieceTableEnd = pieceTableStart + this.m_lcbClx; 12 this.m_stream.Seek(pieceTableStart, SeekOrigin.Begin); 13 14 Byte clxt = this.m_reader.ReadByte(); 15 Int32 prcLen = 0; 16 17 //判斷如果是Prc不是Pcdt 18 while (clxt == 0x01 && this.m_stream.Position < pieceTableEnd) 19 { 20 this.m_stream.Seek(prcLen, SeekOrigin.Current); 21 clxt = this.m_reader.ReadByte(); 22 prcLen = this.m_reader.ReadInt32(); 23 } 24 25 if (clxt != 0x02) 26 { 27 throw new Exception("該文件不存在內容!"); 28 } 29 30 UInt32 size = this.m_reader.ReadUInt32(); 31 UInt32 count = (size - 4) / 12; 32 33 this.m_lstPieceStartPosition = new List<UInt32>(); 34 this.m_lstPieceEndPosition = new List<UInt32>(); 35 this.m_lstPieceElement = new List<PieceElement>(); 36 37 for (Int32 i = 0; i < count; i++) 38 { 39 this.m_lstPieceStartPosition.Add(this.m_reader.ReadUInt32()); 40 this.m_lstPieceEndPosition.Add(this.m_reader.ReadUInt32()); 41 this.m_stream.Seek(-4, SeekOrigin.Current); 42 } 43 44 this.m_stream.Seek(4, SeekOrigin.Current); 45 46 for (Int32 i = 0; i < count; i++) 47 { 48 UInt16 info = this.m_reader.ReadUInt16(); 49 UInt32 fcCompressed = this.m_reader.ReadUInt32(); 50 UInt16 prm = this.m_reader.ReadUInt16(); 51 52 this.m_lstPieceElement.Add(new PieceElement(info, fcCompressed, prm)); 53 } 54 }
上頭我們可以獲取到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]……
所以我們編寫如下代碼獲取:

1 #region 字段 2 private String m_paragraphText; 3 private String m_footnoteText; 4 private String m_headerText; 5 private String m_commentText; 6 private String m_endnoteText; 7 private String m_textboxText; 8 private String m_headerTextboxText; 9 #endregion 10 11 #region 屬性 12 /// <summary> 13 /// 獲取文檔正文內容 14 /// </summary> 15 public String ParagraphText 16 { 17 get { return this.m_paragraphText; } 18 } 19 20 /// <summary> 21 /// 獲取文檔頁腳內容 22 /// </summary> 23 public String FootnoteText 24 { 25 get { return this.m_footnoteText; } 26 } 27 28 /// <summary> 29 /// 獲取文檔頁眉內容 30 /// </summary> 31 public String HeaderText 32 { 33 get { return this.m_headerText; } 34 } 35 36 /// <summary> 37 /// 獲取文檔批注內容 38 /// </summary> 39 public String CommentText 40 { 41 get { return this.m_commentText; } 42 } 43 44 /// <summary> 45 /// 獲取文檔尾注內容 46 /// </summary> 47 public String EndnoteText 48 { 49 get { return this.m_endnoteText; } 50 } 51 52 /// <summary> 53 /// 獲取文檔文本框內容 54 /// </summary> 55 public String TextboxText 56 { 57 get { return this.m_textboxText; } 58 } 59 60 /// <summary> 61 /// 獲取文檔頁眉文本框內容 62 /// </summary> 63 public String HeaderTextboxText 64 { 65 get { return this.m_headerTextboxText; } 66 } 67 #endregion 68 69 #region 讀取文本內容 70 private void ReadPieceText() 71 { 72 StringBuilder sb = new StringBuilder(); 73 DirectoryEntry entry = this.m_dirRootEntry.GetChild("WordDocument"); 74 75 for (Int32 i = 0; i < this.m_lstPieceElement.Count; i++) 76 { 77 Int64 pieceStart = this.GetSectorOffset(entry.SectorID) + this.m_lstPieceElement[i].Offset; 78 this.m_stream.Seek(pieceStart, SeekOrigin.Begin); 79 80 Int32 length = (Int32)((this.m_lstPieceElement[i].IsUnicode ? 2 : 1) * (this.m_lstPieceEndPosition[i] - this.m_lstPieceStartPosition[i])); 81 Byte[] data = this.m_reader.ReadBytes(length); 82 String content = GetString(this.m_lstPieceElement[i].IsUnicode, data); 83 sb.Append(content); 84 } 85 86 String allContent = sb.ToString(); 87 Int32 paragraphEnd = this.m_ccpText; 88 Int32 footnoteEnd = paragraphEnd + this.m_ccpFtn; 89 Int32 headerEnd = footnoteEnd + this.m_ccpHdd; 90 Int32 commentEnd = headerEnd + this.m_ccpAtn; 91 Int32 endnoteEnd = commentEnd + this.m_ccpEdn; 92 Int32 textboxEnd = endnoteEnd + this.m_ccpTxbx; 93 Int32 headerTextboxEnd = textboxEnd + this.m_ccpHdrTxbx; 94 95 this.m_paragraphText = allContent.Substring(0, this.m_ccpText); 96 this.m_footnoteText = allContent.Substring(paragraphEnd, this.m_ccpFtn); 97 this.m_headerText = allContent.Substring(footnoteEnd, this.m_ccpHdd); 98 this.m_commentText = allContent.Substring(headerEnd, this.m_ccpAtn); 99 this.m_endnoteText = allContent.Substring(commentEnd, this.m_ccpEdn); 100 this.m_textboxText = allContent.Substring(endnoteEnd, this.m_ccpTxbx); 101 this.m_headerTextboxText = allContent.Substring(textboxEnd, this.m_ccpHdrTxbx); 102 } 103 #endregion 104 105 private String GetString(Boolean isUnicode, Byte[] data) 106 { 107 if (isUnicode) 108 { 109 return Encoding.Unicode.GetString(data); 110 } 111 else 112 { 113 return Encoding.GetEncoding("Windows-1252").GetString(data); 114 } 115 }
不過需要注意的是,由於Word文檔中的換行為“\r”(CR),而Windows中的換行符為“\r\n”(CR+LF),所以獲取文字后需要將“\r”替換為“\r\n”,否則換行將無法正常顯示,除此之外,還有其他的一些特殊字符也需要替換或處理。
附,本文所有代碼下載: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
【后記】
本還想周日晚上發出來,結果還是沒寫完。希望這次的文章能對大家有用。如果您覺得好就點下推薦唄。