简介:这份资源面向Java后端与前端开发者,聚焦Word内容提取与Word转HTML的完整实现,解决文档跨平台共享与网页展示的格式转换问题。包内共131个文件,以92个xml配置、22个jpeg图片素材为主,另含properties配置、class字节码、java源码及mvnw构建脚本等,压缩包约726KB,结构清晰便于按模块查阅。内容围绕Apache POI展开,涵盖HWPFFactory与XWPFDocument分别处理doc与docx、逐段遍历提取文本与字体字号颜色等样式、图片经ImageIO转存并以img标签引用、表格映射为table/tr/td并保留边框对齐,以及WPS文档兼容性适配等要点。已有1370人学习下载,适合需要快速掌握文档解析与HTML生成思路、对照源码查漏补缺的开发者参考。
1. 从一份“打不开”的文档说起:POI 把 Word 拆成 HTML 的完整方案
有次运营同事丢过来一个压缩包,里面躺着两百多份.doc和.docx,要求全部转成网页能直接预览的 HTML,还得保留标题层级、表格和图片。我第一反应是找在线转换工具,结果批量一跑,格式全乱,表格塌成一行,图片直接丢。后来老老实实回到 Java 生态里最稳的那条路——用 Apache POI 把 Word 的文档模型读出来,再自己拼 HTML。这套思路不依赖 Office 组件,服务端能跑,前端拿到 HTML 直接塞进容器就能渲染,特别适合做文档预览、内容迁移、富文本回显这类场景。下面就把这份 Word 转 HTML 的工程实现拆开讲,从依赖、目录结构到核心转换代码,再到怎么改、怎么避坑,一次说清。
2. 技术选型与工程骨架:为什么是 POI 而不是别的
2.1 两条转换路线的取舍
Word 转 HTML 在 Java 里常见做法有两类。一类是调 LibreOffice 或 OpenOffice 的命令行做无头转换,优点是格式还原度高,缺点是得在服务器装几百兆的办公套件,进程重、并发差,容器化部署时镜像体积直接翻倍。另一类就是 POI 直接解析,纯 Java 依赖,启动快、可控性强,缺点是复杂排版(比如文本框、艺术字)还原有限,需要自己补样式。
这个项目选的是 POI 路线,原因很实际:目标文档以正文、标题、表格、图片为主,没有花哨的排版,POI 完全够用,而且能把转换逻辑嵌进业务代码里,边转边做内容清洗,比如去掉页眉页脚、统一图片路径。如果你的文档里有大量复杂图文混排,那得评估 LibreOffice 方案,但对绝大多数后台系统的文档预览需求,POI 是性价比最高的选择。
2.2 依赖与版本注意点
POI 处理 Word 分两个包:poi-scratchpad负责老的.doc(HWPF 模型),poi-ooxml负责新的.docx(XWPF 模型)。两个都要引,因为实际业务里两种格式往往混着来。
<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.3</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>5.2.3</version> </dependency>这里有个血泪经验:POI 5.x 对poi-ooxml的传递依赖比较敏感,如果项目里同时引了老版本的xmlbeans或commons-compress,很容易在解析.docx时抛NoClassDefFoundError。我一般会在dependencyManagement里把xmlbeans锁到 5.x 对应版本,避免和别的组件打架。另外.doc的 HWPF 模型对中文编码支持一般,读出来的文本偶尔有乱码,后面避坑章节会讲怎么处理。
2.3 工程目录结构
项目按职责拆成四层,结构清晰,二次开发时改哪块一目了然:
word2html/ ├── src/main/java/com/example/word2html/ │ ├── converter/ │ │ ├── WordToHtmlConverter.java // 转换入口,判断 doc/docx │ │ ├── DocxConverter.java // XWPF 解析实现 │ │ └── DocConverter.java // HWPF 解析实现 │ ├── model/ │ │ └── HtmlNode.java // 中间模型,承载段落/表格/图片 │ ├── util/ │ │ ├── ImageUtil.java // 图片抽取与 base64 编码 │ │ └── StyleUtil.java // 样式映射 │ └── controller/ │ └── ConvertController.java // 对外 HTTP 接口 ├── src/main/resources/ │ └── application.yml └── pom.xml中间模型HtmlNode是关键设计。不直接把 POI 对象转成字符串,而是先转成统一的节点树,再统一渲染 HTML。这样做的好处是:.doc和.docx两套解析逻辑最终产出同一种结构,渲染层只写一遍,后续要加 Markdown 输出也只是多一个渲染器。
3. 核心转换链路:从 XWPF 文档到 HTML 字符串
3.1 转换入口与格式分发
入口方法只做一件事:看扩展名,分发到对应实现。别小看这个判断,.doc和.docx的底层模型完全不同,混用会直接抛异常。
public class WordToHtmlConverter { /** * 统一转换入口 * @param inputStream 文档流 * @param fileName 原始文件名,用于判断格式 * @return 转换后的 HTML 字符串 */ public String convert(InputStream inputStream, String fileName) throws IOException { if (fileName == null) { throw new IllegalArgumentException("文件名不能为空,无法判断文档格式"); } String lower = fileName.toLowerCase(); if (lower.endsWith(".docx")) { return new DocxConverter().convert(inputStream); } else if (lower.endsWith(".doc")) { return new DocConverter().convert(inputStream); } throw new UnsupportedOperationException("仅支持 doc 与 docx 格式"); } }逻辑说明:入口不碰任何 POI 对象,只负责路由,这样单元测试时可以用 mock 流分别验证两条分支。参数fileName必须传原始名,因为流本身不带格式信息。实际项目里我一般还会加一个文件头魔数校验,防止有人把.docx改名成.doc导致解析失败。
3.2 段落与标题的解析
.docx用XWPFDocument遍历,核心是拿到每个段落的样式 ID,映射成 HTML 的<h1>到<h6>或<p>。
public String convert(InputStream in) throws IOException { StringBuilder html = new StringBuilder(); try (XWPFDocument doc = new XWPFDocument(in)) { // 遍历文档顶层元素,段落和表格是并列的 for (IBodyElement element : doc.getBodyElements()) { if (element instanceof XWPFParagraph) { html.append(parseParagraph((XWPFParagraph) element)); } else if (element instanceof XWPFTable) { html.append(parseTable((XWPFTable) element)); } } } return html.toString(); } private String parseParagraph(XWPFParagraph para) { String text = para.getText(); if (text == null || text.trim().isEmpty()) { return "<p></p>"; } // 样式 ID 形如 Heading1、Heading2,映射为 h 标签 String styleId = para.getStyleID(); String tag = "p"; if (styleId != null && styleId.startsWith("Heading")) { int level = Integer.parseInt(styleId.replace("Heading", "")); level = Math.min(Math.max(level, 1), 6); // 限制在 h1-h6 tag = "h" + level; } // 转义 HTML 特殊字符,防止 XSS 和标签错乱 String safe = escapeHtml(text); return "<" + tag + ">" + safe + "</" + tag + ">"; }逻辑说明:getBodyElements()返回的是有序列表,段落和表格按文档中的真实顺序排列,这样渲染出来不会出现表格跑到段落前面的错位。getStyleID()拿到的可能是Heading1也可能是1(取决于文档生成工具),所以生产代码里我一般会加一层样式名兜底判断,用para.getStyle()再查一次。escapeHtml必须做,否则文档里出现<或&会把 HTML 结构冲垮。
3.3 表格解析与合并单元格处理
表格是转换里最容易翻车的部分。XWPFTable的行列结构相对规整,但合并单元格需要读getGridSpan()和getVerticalMerge()。
private String parseTable(XWPFTable table) { StringBuilder sb = new StringBuilder("<table border=\"1\" cellspacing=\"0\">"); for (XWPFTableRow row : table.getRows()) { sb.append("<tr>"); for (XWPFTableCell cell : row.getTableCells()) { // 水平合并:gridSpan 大于 1 时加 colspan int colspan = 1; if (cell.getCTTc().getTcPr() != null && cell.getCTTc().getTcPr().getGridSpan() != null) { colspan = cell.getCTTc().getTcPr().getGridSpan().getVal().intValue(); } sb.append("<td colspan=\"").append(colspan).append("\">"); sb.append(escapeHtml(cell.getText())); sb.append("</td>"); } sb.append("</tr>"); } sb.append("</table>"); return sb.toString(); }逻辑说明:getCTTc()拿到的是底层 XML 对象,getGridSpan()返回水平合并的列数。垂直合并(vMerge)更麻烦,需要跨行记录状态,这里先给水平合并的版本,垂直合并的思路是在遍历时维护一个“上一行该列是否已合并”的标记,遇到continue类型就跳过输出。参数border和cellspacing是内联样式,实际项目里建议抽到 CSS 类里,方便前端统一改表格样式。
3.4 图片抽取与内联
图片有两种处理方式:存成独立文件返回 URL,或者直接 base64 内联进 HTML。内联的好处是 HTML 自包含,前端一个字符串就能渲染,适合接口返回;坏处是文档大时字符串膨胀明显。
private String parseImage(XWPFPictureData pictureData) { byte[] bytes = pictureData.getData(); String base64 = Base64.getEncoder().encodeToString(bytes); String mime = pictureData.getPackagePart().getContentType(); // 常见 mime 形如 image/png、image/jpeg return "<img src=\"data:" + mime + ";base64," + base64 + "\" />"; }逻辑说明:XWPFPictureData从doc.getAllPictures()或段落内的 run 里取。getContentType()返回标准 MIME,直接拼进 data URI 即可。注意大图 base64 后体积涨约 33%,如果单文档图片多,建议改成上传到对象存储再回填 URL,这个改造点最后一章会展开。
4. 老格式 .doc 的兼容处理与样式映射
4.1 HWPF 模型的读取差异
.doc用的是HWPFDocument,API 比 XWPF 原始得多,段落和表格的边界没那么清晰,需要靠Range和Paragraph配合。
public String convert(InputStream in) throws IOException { StringBuilder html = new StringBuilder(); try (HWPFDocument doc = new HWPFDocument(in)) { Range range = doc.getRange(); // 遍历所有段落,通过 isInTable 判断是否在表格内 for (int i = 0; i < range.numParagraphs(); i++) { Paragraph para = range.getParagraph(i); if (para.isInTable()) { // 表格内容交给表格解析,避免重复输出 continue; } String text = para.text().replace("\r", "").replace("\u0007", ""); if (text.trim().isEmpty()) { continue; } html.append("<p>").append(escapeHtml(text)).append("</p>"); } } return html.toString(); }逻辑说明:para.text()结尾常带\r和\u0007(单元格结束符),必须清掉,否则 HTML 里会出现乱码方块。isInTable()用来跳过表格内段落,表格单独用doc.getRange().getTable()处理。HWPF 对样式的支持弱,标题识别基本靠字号和加粗,实际项目里我一般会读CharacterRun的getFontSize(),超过 16 磅且加粗就当标题处理。
4.2 样式映射表
把 Word 的样式统一映射成 CSS 类,前端好接管。下面是一份常用映射:
| Word 样式/属性 | HTML 标签/类 | 说明 |
|---|---|---|
| Heading1~6 | h1~h6 | 标题层级 |
| 正文 | p | 默认段落 |
| 加粗 run | strong | 行内加粗 |
| 斜体 run | em | 行内斜体 |
| 表格 | table.doc-table | 统一表格类 |
| 图片 | img.doc-img | 统一图片类 |
映射逻辑放在StyleUtil里,转换时查表。这样前端只要覆盖.doc-table和.doc-img两个类,就能统一控制文档渲染样式,不用去改后端拼接逻辑。
4.3 编码与乱码处理
.doc老文件常见 GBK 编码,POI 读出来偶尔乱码。稳妥做法是在读取流时先探测编码,或者用new String(text.getBytes("ISO-8859-1"), "GBK")做一次转码兜底。这个坑在避坑章节还会细说。
5. 避坑与常见问题排查
5.1 现象:解析 .docx 抛 NoClassDefFoundError
原因:POI 5.x 依赖的xmlbeans版本和项目里其他组件冲突,类加载时找不到。解决:在dependencyManagement里显式锁定xmlbeans到 5.1.1 以上,并执行mvn dependency:tree确认没有旧版本混入。
5.2 现象:表格转换后列错位
原因:合并单元格的gridSpan没处理,或者垂直合并的vMerge被当成普通单元格输出。解决:水平合并读gridSpan加colspan,垂直合并维护跨行状态,遇到vMerge的continue类型直接跳过该单元格输出。
5.3 现象:中文乱码或出现方块
原因:.doc文件编码非 UTF-8,HWPF 默认按 ISO-8859-1 读。解决:读取后做一次编码转换,或引入juniversalchardet探测编码。.docx本身是 XML,一般不会乱码,若出现检查是否在流读取时被提前关闭。
5.4 现象:图片丢失或 base64 过大导致接口超时
原因:图片内联后 HTML 体积暴涨,或者图片在页眉页脚里没被遍历到。解决:大文档改成图片上传对象存储回填 URL;页眉页脚图片需要单独遍历doc.getHeaderList()和getFooterList(),主文档遍历不会覆盖它们。
5.5 现象:转换后样式全丢
原因:只输出了标签没输出样式,或者样式 ID 和预期不符。解决:在StyleUtil里做样式名兜底匹配,同时输出一份默认 CSS 内联在 HTML 头部,保证脱离项目 CSS 也能看。
6. 二次开发改造:从能跑到好用
这套代码跑通之后,真正决定它好不好用的是几个改造点。第一个是图片策略。内联 base64 适合小文档,一旦单文档超过 5MB,接口响应会明显变慢。我一般会加一个开关:小于 200KB 的图片内联,超过的上传到对象存储,HTML 里只留 URL。改造时在ImageUtil里加一个uploadAndGetUrl方法,parseImage根据阈值分流即可。
第二个是内容清洗。实际业务里经常要去掉页眉页脚、批注、修订记录。POI 的XWPFDocument提供getHeaderList()、getFooterList(),遍历时直接跳过就行;修订记录在XWPFParagraph的 run 里,判断run.isInserted()可以过滤。这些清洗逻辑建议单独抽一个DocumentCleaner类,转换前先过一遍,别和转换逻辑搅在一起。
第三个是性能。批量转换时别每次 new 一个XWPFDocument就完事,大文档解析吃内存。我的习惯是用线程池控制并发,单文档超过 10MB 的走单独队列,避免把服务打挂。下面是一个简单的并发控制片段:
// 限制同时转换的文档数,防止内存溢出 private final Semaphore semaphore = new Semaphore(4); public String convertWithLimit(InputStream in, String name) throws Exception { semaphore.acquire(); try { return convert(in, name); } finally { semaphore.release(); } }验证改造是否成功,我一般准备三份样本:一份纯文字带标题,一份含合并单元格表格,一份含多张图片。每次改完跑一遍,对比 HTML 的标签数量和图片数量是否和预期一致。这个习惯是从一次翻车来的——当时改了图片逻辑,结果表格里的图片全丢了,因为表格单元格内的图片遍历路径和段落不一样。从那以后我每次动转换逻辑,都强制走一遍这三份样本,确认标签、表格、图片三类元素数量对得上才提交。希望这套拆解能帮到你,拿到源码后先跑通入口,再按自己的文档特点调样式和图片策略,基本就能落地了。
本文还有配套的精品资源,点击获取