用 Java 操作 Office 2007

阅读数:10388 2007 年 9 月 20 日

话题:JavaDevOps语言 & 开发架构

在上一篇“Office 富客户端应用”中,我们提到了将 Office 2007 平台作为一个构建富客户端应用程序的基本平台,并通过不同的手段使用 Java 来进行互操作。 但是,有一个 Office/Java 互操作的方面没有考虑到,那就是使 Office 和 Java 共同工作,也就是说让 Java 应用程序来操作 Office 文档:比如创建文档,编辑文档,收集数据等等。

从以往看来,这其中经常会出现一些问题,这是由于 Office 文档(主要是 Word,Excel 和 PowerPoint)是存储在一个二进制格式文件中,在 COM 中被称为结构化存储格式, 是一个通过 COM 接口的层次化二进制格式。 对 COM 开发者(或者其他使用 COM 相关语言的开发者,如 Visual Basic, Delphi 和 C++/ATL)而言非常方便,但产生的文件对于那些不能“讲 COM”的语言是无法访问的。有许许多多的应用程序都是为了让 Java 语言可以访问这些文件的内容;比如大家都知道 Excel 可以读取逗号分隔符文件(CSV),因此,Java 应用程序相应将数据导出到 Excel 友好的格式时一般会选用 CSV 格式(或是其他丑陋的格式)。Word 则是可以读取富文本格式(RTF)文件,而 RTF 标准是公开和有详细文档的。Office 的后来者,Office 2003,引入了一个新的 XML 格式(WordML),Java 开发者可以用它来读写 Office 文档,但是这些格式并没有很好的文档,Java 开发者频繁的发现自己是通过试错法来进行 WordML 格式的学习。 各种各样的开源项目都参与进来想要解决这个问题,比如 Apache 的 POI 框架,可以用来读写 Excel 文档,还有各种各样的 Java-COM 解决方案,这些解决方案一般倾向于使用和 Office 自己使用的结构化存储应用程序接口相同的应用程序接口进行 Excel 文档的读写,但很难满足需要,直到现在,开发者不得不指出 Office 文档格式的内部结构是一个非常复杂的结构,另外一点毋庸置疑的是它是一个没有完整文档的结构。

总体上来说,如果温和一点说的话,Java/Office 的故事是一个非常讨厌的境况。对于 Java 的开发人员而言,他们要么一边嘴里说着“Office 这种破东西怎么还会有人想去用它”一边用记忆里的伊索寓言来安慰自己,要么干脆告诉那些使用 Office 的客户由于 Microsoft 和 Sun 两家公司之间的诉讼,Java 不能操作 Office。

对于 Office 2007 来说,微软毫无疑问的迈出了解决这些问题的一大步。没有比原始的 JDK 更复杂的东西 --- 也就是说并不要求使用一些第三方的库 ---Java 应用程序现在可以读写任何 Office 2007 的文档,这是由于 Office 2007 文档现在使用的是 XML 文档的 ZIP 格式文件。 这种格式被称作“OpenMXL”规范并且已经被提交到欧洲计算机制造商协会(ECMA),这个协会同样拥有 C# 语言和 CLI 运行时规范,所有的 OpenXML 规范现在都可以被任何人自由的从ECMA的网站下载。 除了这些,再安装好 Office 2007(为了验证和作一些测试)和一个标准的 Java6 JDK 安装,Java 现在可以打开任何的 Office 2007 文档,找出来文档中间的内容,操作它们,并且再次保存这些数据。

与上篇文章不同,在这篇文章中,除了创建一个简单的应用程序之外,代码将会使用一种首先由 Stuart Halloway 提出的、被称作探索测试(exploration testing)的技术。在一个探索测试中,开发者编写单元测试用来探索应用程序接口,使用单元测试世界中的断言验证结果的正确性。探索测试带来的好处是当一个新版本的应用程序接口可用时 --- 在这个例子中,可能是一个新版本的 Office--- 运行这些测试可以用来确认新版本的采用不会影响到原本对应用程序接口的使用。

对于初学者来说,让我们首先快速的了解一下 Office 2007 文档。首先看一个仅仅包含文本的 Word 2007 文档,就像下面一样:

当保存的时候,使用 Word 2007 将它保存为“Hello.docx”,除非你使用了向后兼容格式,比如说 Office 2003 的 WordML 格式,或者是更老的 Word 97 二进制结构化存储格式。“.docx”文件是 OpenXML 格式的,微软的文档中声称该格式是 XML 文档的 ZIP 压缩格式文件,这些文件中包含了文档中的数据和格式,存储的方式与之前的 Office 版本中的二进制结构化存储应用程序接口存储数据的方式有些类似。如果这是真的,那么使用 Java 中提供的用来处理 ZIP 和 TAR 格式的“jar”实用工具应该可以展示这些内容,而事实上,它的确可以:

Word 2007 文档的基本格式已经非常明显了,仅仅通过控制台的输出就可以看到。(事实上,“jar”实用工具所展示的这激动人心的一切,说明 java.util.jar 和 / 或 java.util.zip 包同样可以简单的访问这些内容。)几乎没有对规范作任何的破解,很明显,文档中的主要内容应该被存储到了“document.xml”文件中,剩余的其他 XML 文件则应该是各种各样的辅助部分,比如文档中应用到的字体(fontTable.xml)和使用到的 Office 主题(theme/theme1.xml),等等。

是时间来编写一些探索测试了。(我们鼓励感兴趣的读者打开一个文本编辑器或者集成开发环境,并将下面的内容填入你的 JUnit 4 测试类当中,并且扩展这些测试。) 使用 JUnit 4,第一个测试是为了简单的确认文件在我们预想的位置(显然这是下面测试可以运行的一个必要的需求)。

@Test public void verifyFileIsThere() {

assertTrue(new File("hello.docx").exists());

assertTrue(new File("hello.docx").canRead());

assertTrue(new File("hello.docx").canWrite());

}

下面的测试简单的验证了我们可以使用 Java 库中的 java.util.zip.ZipFile 来打开这个文件:

@Test public void openFile()

throws IOException, ZipException

{

ZipFile docxFile =

new ZipFile(new File("hello.docx"));

assertEquals(docxFile.getName(), "hello.docx");

}

现在一切看来都非常不错。Java 的 ZipFile 类正确的识别了我们的文件,一个 zip 文件,如果我们还能继续保持这样的运气,让我们继续我们的测试,来遍历一下,识别文档中的内容并找出其中的数据。让我们编写一个快速的测试来从“document.xml”文件中找出所有的内容。

@Test public void listContents()

throws IOException, ZipException

{

boolean documentFound = false;

ZipFile docxFile =



new ZipFile(new File("hello.docx"));

Enumeration entriesIter =

docxFile.entries();

while (entriesIter.hasMoreElements())

{

ZipEntry entry = entriesIter.nextElement();

if (entry.getName().equals("document.xml"))



documentFound = true;

}

assertTrue(documentFound);

}

令人诧异的是,当我们运行测试的时候,测试过程产生了一个失败;并没有找到“document.xml”文件,这是由于 ZipFile/ZipEntry 应用程序接口需要压缩文件中完整的路径名称。将测试中的路径改为“word/document.xml”,测试就通过了。

很好,我们已经找到文件了,下面让我们打开这个文件看看 XML 里面是什么。这非常简单,因为 ZipFile 有一个返回 ZipEntry 的应用程序接口。

@Test public void getDocument()

throws IOException, ZipException

{

ZipFile docxFile =

new ZipFile(new File("hello.docx"));

ZipEntry documentXML =

docxFile.getEntry("word/document.xml");

assertNotNull(documentXML);

}

ZipFile 代码可以返回它包含的实体内容,通过调用getInputStream()方法即可,不要对 InputStream 产生任何怀疑。将 InputStream 发送到一个 DOM 节点中就可以创建一个关于该文档的 DOM。

@Test public void fromDocumentIntoDOM()

throws IOException, ZipException, SAXException,

ParserConfigurationException

{

ZipFile docxFile =

new ZipFile(new File("hello.docx"));

ZipEntry documentXML =

docxFile.getEntry("word/document.xml");

InputStream documentXMLIS =

docxFile.getInputStream(documentXML);

DocumentBuilderFactory dbf =

DocumentBuilderFactory.newInstance();

Document doc =

dbf.newDocumentBuilder().parse(documentXMLIS);

assertEquals("[w:document: null]",



doc.getDocumentElement().toString());

}

事实上,与其他支持各种 Word 所需格式的 XML 文档相比,document.xml 文件的内容(为了明显起见,将命名空间声明等内容去除)看起来也相当乏味:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>

<w:document ...>

<w:body>

<w:p w:rsidR="00DE36E5" w:rsidRDefault="00DE36E5">

<w:r>

<w:t>Hello, from Office 2007!</w:t>

</w:r>

</w:p>

<w:sectPr w:rsidR="00DE36E5">

<w:pgSz w:w="12240" w:h="15840"/>

<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>

<w:cols w:space="720"/>

<w:docGrid w:linePitch="360"/>

</w:sectPr>

</w:body>

</w:document>

关于文档中各个元素具体代表什么内容的细节已经超出了这篇文章的讨论范围,读者可以查阅 OpenXML 文档的具体内容来获得参考,但是文档中的主要内容是十分明显的。比如说文档中包括“p”元素(段落),包括“r”元素(文本区),包括“t”元素(文本),在本例的 hello.docx 文档中,单句“Hello from Office 2007”就是由这些元素构成的。

读过文件的内容后,现在可以来修改这些内容了,将其写到文件中,并用 Word 2007 打开它。快速的查看 ZipFile 和 ZipEntry 的应用程序接口可以发现这样一个问题:尽管这些类可以用来读取一个 zip 文件,但它们并不能写入或创建它们。

有很多可用的方法可以用于解决这个问题。一个简单的方法是将 XML 文件的内容文本写到一个字符串中,并将这个字符串存储到 document.xml 文件中,然后重新使用 ZipOutStream 类压缩所有的内容。另一个方法是使用一些可以编辑 zip 文件内容的第三方工具(或创建一个),但这些已经脱离了 JDK 的基本内容,所以在这篇文章中我们将使用 ZipOutStream 方法。

为了达到我们的目的,我们需要做很多事情。首先,Java 应用程序必须定位到 DOM 的层次结构中,找到“t”节点,然后将它的文本内容替换为我们要写入到 Word 文档中的内容。(“Hello,Office 2007,from Java6!”是个不错的选择)产生的新 DOM 实例必须要保存到磁盘中,使用 Java XML 应用程序接口时这并不是一个简单的任务。(简单的说来,开发者需要从 javax.xml.transform 包中创建一个 Transformer,然后将 XML 转换为一个 StreamResult,再交由 ByteArrayOutputStream 处理。)

一旦上面这些事情都处理完毕后,代码必须要产生一个 ZIP 格式的文件,是时候使用 ZipOutputStream 了,但由于只需要改变文档的内容,而不需要改变它的样式、字体以及格式,其他的部分可以从原始的文件中拷贝过来。使用一个简单的循环,遍历原始文件中的 ZipEntries 中所有的内容(除了 word/document.xml,该文件中的内容需要被改变)并将其导出到一个新的 ZipEntry 中并写入该实体就足够了。当所有的工作都完成后,代码将会是以下的样子:

@Test public void modifyDocumentAndSave()

throws IOException, ZipException, SAXException,

ParserConfigurationException,

TransformerException,

TransformerConfigurationException

{

ZipFile docxFile =

new ZipFile(new File("hello.docx"));

ZipEntry documentXML =

docxFile.getEntry("word/document.xml");

InputStream documentXMLIS =

docxFile.getInputStream(documentXML);

DocumentBuilderFactory dbf =

DocumentBuilderFactory.newInstance();

Document doc =

dbf.newDocumentBuilder().parse(documentXMLIS);

Element docElement = doc.getDocumentElement();



assertEquals("w:document", docElement.getTagName());

Element bodyElement = (Element)



docElement.getElementsByTagName("w:body").item(0);

assertEquals("w:body", bodyElement.getTagName());

Element pElement = (Element)



bodyElement.getElementsByTagName("w:p").item(0);

assertEquals("w:p", pElement.getTagName());

Element rElement = (Element)



pElement.getElementsByTagName("w:r").item(0);

assertEquals("w:r", rElement.getTagName());

Element tElement = (Element)



rElement.getElementsByTagName("w:t").item(0);

assertEquals("w:t", tElement.getTagName());

assertEquals("Hello, from Office 2007!",



tElement.getTextContent());

tElement.setTextContent(



"Hello, Office 2007, from Java6!");

Transformer t =



TransformerFactory.newInstance().newTransformer();

ByteArrayOutputStream baos =

new ByteArrayOutputStream();

t.transform(new DOMSource(doc),

new StreamResult(baos));

ZipOutputStream docxOutFile = new ZipOutputStream(



new FileOutputStream("response.docx"));

Enumeration entriesIter =

docxFile.entries();

while (entriesIter.hasMoreElements())

{

ZipEntry entry = entriesIter.nextElement();

if (entry.getName().equals("word/document.xml"))



{

byte[] data = baos.toByteArray();

docxOutFile.putNextEntry(

new ZipEntry(entry.getName()));

docxOutFile.write(data, 0, data.length);

docxOutFile.closeEntry();

}

else

{

InputStream incoming =

docxFile.getInputStream(entry);

byte[] data = new byte[1024 * 16];

int readCount =

incoming.read(data, 0, data.length);

docxOutFile.putNextEntry(

new ZipEntry(entry.getName()));

docxOutFile.write(data, 0, readCount);

docxOutFile.closeEntry();

}

}

docxOutFile.close();

}

很抱歉这里展示了这么多代码,但是说实在的,这也是 Java 相比其他语言或者库的一个弱点。幸运的是我们的努力得到了以下的回报:

显然我们可以作很多事情来改善上面的场景。

首先,一个更好的 XML 操作库,可以更好的支持 XPath 技术,能够原生的序列化 XML DOM 结构到磁盘的库会对减少大量的代码有所帮助。JDOM,一个开源的 Java/XML 库(可以在 jdom.org 中找到),是一个可用的选择。Apache 的 XMLBeans 也不错。一个必然的结果是我们可以获得更好的描述 OpenXML 格式的模式文档,并使用它们来产生一系列的 Java 类来更好的反映 OpenXML 文档的格式。开发者则可以更好的使用原生的 Java 类工作,而不是通过“Document”类和“Element”类。

其次,这些方法可以被绑定到一个更加针对 Office 的应用程序接口当中,可以改善针对实际存储的 Word(或是 Excel,PowerPoint)文档的 XML 文件操作的抽象层,关注那些拥有段落,字体等等其他的文档。实质上,像 POI 那样的库应该可以通过更新类反映 Office XML 格式的改动,理想的话,可以同时支持写入二进制结构化存储格式和新的 OpenXML 格式。

再次,Java 可以对其 ZIP 文件格式的支持进行一些改动,同样,这样的目的也可以由使用一些第三方的库来完成。

尽管使用了一些笨重的应用程序接口调用,但是当想到 Office 平台对 Java 开发人员有多开放时还是非常的令人激动和振奋。在 Java 和 Office 应用程序的互操作性上,在 Java 应用程序中使用 Office,还有在 Java 中创建和读写 Office 文件格式上,Office 平台对 Java 社区的开发人员比以往任何时候都更加开放了。

本文附带的示例代码可以在此处下载

查看英文原文:Using Java to Crack Office 2007
译者简介:张立,博士研究生,喜欢新技术,新思想。经历了一些企业级软件开发后,逐渐将兴趣转向 C# 和 JAVA 的企业级应用。同时对动态语言的发展非常关注,喜欢用 Python 进行一些计算,对 Ruby 也倾注了一定的精力。大部分时间在学校从事一些理论研究,工作之余关注开源软件的进展。