简介这份资源面向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负责老的.docHWPF 模型poi-ooxml负责新的.docxXWPF 模型。两个都要引因为实际业务里两种格式往往混着来。!-- pom.xml 关键依赖 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version5.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用的是HWPFDocumentAPI 比 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~6h1~h6标题层级正文p默认段落加粗 runstrong行内加粗斜体 runem行内斜体表格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-8HWPF 默认按 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 的标签数量和图片数量是否和预期一致。这个习惯是从一次翻车来的——当时改了图片逻辑结果表格里的图片全丢了因为表格单元格内的图片遍历路径和段落不一样。从那以后我每次动转换逻辑都强制走一遍这三份样本确认标签、表格、图片三类元素数量对得上才提交。希望这套拆解能帮到你拿到源码后先跑通入口再按自己的文档特点调样式和图片策略基本就能落地了。本文还有配套的精品资源点击获取