首页 / 资讯中心 / 文章详情

Java 使用 Apache POI 将 Word 文档转换为 HTML 的完整实践

Java 使用 Apache POI 将 Word 文档转换为 HTML 的完整实践 ★ FEATURED ARTICLE
简介这份资源面向Java后端与前端开发者聚焦Word文档内容提取及Word转HTML的完整实现基于Apache POI库处理.doc、.docx及WPS文档。内容涵盖文本、样式、图片、表格的读取与转换并涉及字体颜色、字号、加粗斜体等样式在HTML中的映射以及图片转JPEG/PNG、表格生成table/tr/td等具体处理思路适合需要跨平台共享或网页展示文档的开发者参考。资源包共131个文件以92个xml配置、22个jpeg图片、3个properties、3个java源码及class文件为主另含mvnw、jar等构建依赖压缩包约726KB结构完整便于直接运行调试。目前已有1370人学习下载。通过该资源可掌握POI读取Word并生成HTML的完整链路理解样式与图片表格的转换细节同时获得WPS兼容性适配与前端展示优化的实践参考对文档处理类项目开发具有较高借鉴价值。1. 从一份“打不开”的文档说起POI 把 Word 拆成 HTML 的完整思路你有没有遇到过这种场景后台管理系统里运营上传了一份.docx产品说明书前端需要直接在网页上预览而不是让用户下载后再用本地软件打开。更麻烦的是这份文档里还有表格、图片、标题层级和加粗样式直接读二进制流塞给浏览器根本没法看。这个项目的切入点就在这里——用 Apache POI 把 Word.doc和.docx解析出来再转换成浏览器能直接渲染的 HTML。它适合两类人一类是需要在 Java 后端做文档在线预览、内容提取的开发者另一类是接手了老系统发现里面混着 WPS 生成的.doc和 Office 生成的.docx想统一处理成 HTML 的人。技术栈上核心是 POI 的HWPFDocument和XWPFDocument两套 API配合前端 HTML 渲染把“文档内容提取”和“格式还原”这两件事拆开做。2. 技术选型与目录结构为什么是 POI 而不是别的2.1 两套 API 对应两种格式别混用Word 的.doc和.docx在底层完全是两种东西。.doc是二进制复合文档格式POI 里对应的是HWPFDocument.docx本质是一个 ZIP 包里面是一堆 XMLPOI 里对应的是XWPFDocument。很多新手会踩的第一个坑就是拿XWPFDocument去读.doc结果直接抛OfficeXmlFileException。这个项目在入口处就做了格式判断根据文件扩展名或文件头魔数分流到不同的解析器。// 根据文件后缀分流实际项目中建议同时校验文件头 public DocumentParser getParser(String fileName) { if (fileName.endsWith(.docx)) { return new XwpfParser(); // 处理 OOXML 格式 } else if (fileName.endsWith(.doc)) { return new HwpfParser(); // 处理二进制复合文档 } throw new IllegalArgumentException(不支持的文件格式: fileName); }这里的DocumentParser是一个接口两个实现类分别封装 POI 的调用。这样做的好处是后续如果要加.rtf或.odt只需要新增实现类不用改调用方。参数上fileName建议用StringUtils.getFilenameExtension这类工具先做小写归一化避免.DOCX大写后缀漏判。2.2 目录结构解析、转换、输出三层分离一个能维护的文档转换项目目录不会把所有逻辑塞在一个类里。常见的做法是按职责分三层parser层负责从 Word 里读出段落、表格、图片converter层负责把读出的元素映射成 HTML 标签render层负责拼装完整的 HTML 文档包括head里的样式和编码声明。src/main/java/com/example/word2html/ ├── parser/ │ ├── DocumentParser.java // 解析接口 │ ├── HwpfParser.java // .doc 解析 │ └── XwpfParser.java // .docx 解析 ├── converter/ │ ├── ParagraphConverter.java // 段落转 p/h1~h6 │ ├── TableConverter.java // 表格转 table │ └── ImageConverter.java // 图片转 base64 或独立文件 ├── render/ │ └── HtmlRenderer.java // 拼装完整 HTML └── util/ └── FileTypeUtil.java // 文件类型判断这个结构不是凭空设计的。parser层只依赖 POI不关心 HTMLconverter层只关心“段落对应什么标签”不关心 POI 的 API 细节render层只做字符串拼接和转义。三层之间通过一个中间模型比如ListDocumentElement传递数据这样任何一层要替换实现都不会牵一发动全身。2.3 依赖引入POI 版本和日志要一起管POI 的依赖不是引入一个poi就完事。处理.docx需要poi-ooxml处理.doc需要poi-scratchpad而poi-ooxml又依赖poi-ooxml-schemas和xmlbeans。版本不一致时最常见的报错是NoSuchMethodError或ClassNotFoundException。!-- pom.xml 中建议统一用同一个 poi.version -- properties poi.version5.2.3/poi.version /properties dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version${poi.version}/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version${poi.version}/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version${poi.version}/version /dependency /dependencies提示如果项目里同时用了其他依赖比如某些报表工具也引了 POI一定要用mvn dependency:tree检查版本冲突否则运行时报的错会非常“玄学”。3. 核心解析链路从 Word 段落读到 HTML 标签3.1 段落解析先判断样式再决定标签Word 里的段落本身不带“这是标题”的语义它带的是样式 ID 或样式名称。POI 的XWPFParagraph提供了getStyle()和getStyleID()但不同版本的 Word 和 WPS 生成的样式名可能不一样。稳妥的做法是结合样式名和字号、加粗等属性综合判断。// 将 XWPFParagraph 映射为 HTML 标签 public String convertParagraph(XWPFParagraph paragraph) { String style paragraph.getStyle(); // 样式名如 Heading1 String text paragraph.getText(); // 段落纯文本 if (text null || text.trim().isEmpty()) { return ; // 空段落直接跳过 } String tag p; // 默认普通段落 if (style ! null) { if (style.startsWith(Heading)) { // Heading1 - h1, Heading2 - h2 int level Integer.parseInt(style.replace(Heading, )); tag h Math.min(level, 6); // 最多到 h6 } else if (style.contains(Title)) { tag h1; } } // 对文本做 HTML 转义防止 XSS 和标签错乱 String escaped escapeHtml(text); return tag escaped / tag ; }这段代码的关键点有三个。第一getStyle()返回的样式名依赖文档本身不能假设一定是Heading1所以用startsWith做前缀匹配。第二Math.min(level, 6)是防止 Word 里出现Heading7这种超出 HTML 范围的标签。第三escapeHtml必须做否则文档里如果有script文本直接拼进 HTML 就会出安全问题。参数上paragraph.getText()只返回纯文本会丢失加粗、斜体等行内样式如果要保留需要遍历paragraph.getRuns()对每个 run 单独判断isBold()、isItalic()。3.2 表格解析行列遍历和合并单元格处理表格是 Word 转 HTML 里最容易翻车的部分。POI 的XWPFTable提供了getRows()和getTableCells()但合并单元格会导致某一行实际列数和表头不一致。常见做法是先按最大列数生成table再根据getGridSpan()补colspan。public String convertTable(XWPFTable table) { StringBuilder sb new StringBuilder(table border1 cellspacing0); for (XWPFTableRow row : table.getRows()) { sb.append(tr); for (XWPFTableCell cell : row.getTableCells()) { // 获取横向合并数默认 1 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(); }getGridSpan()返回的是BigInteger所以用intValue()转成 int。纵向合并vMerge更麻烦需要记录上一行同列是否已经输出过这里为了简洁先不展开。实际项目中如果表格结构复杂建议先把表格数据抽成一个二维数组再统一渲染这样合并逻辑会清晰很多。3.3 图片提取base64 内嵌还是独立文件Word 里的图片存在XWPFPictureData里可以通过document.getAllPictures()拿到。转 HTML 时有两种策略一是转成 base64 直接内嵌到img srcdata:image/png;base64,...二是保存成独立文件HTML 里引用相对路径。前者适合单文件输出后者适合图片多、需要 CDN 的场景。// 将图片转为 base64 内嵌 public String convertImage(XWPFPictureData picture) { byte[] data picture.getData(); String base64 Base64.getEncoder().encodeToString(data); String mime picture.getPictureTypeEnum().getContentType(); // image/png 等 return img srcdata: mime ;base64, base64 /; }getPictureTypeEnum().getContentType()返回的是标准 MIME 类型直接拼到data:后面即可。需要注意的是base64 会让 HTML 体积膨胀约 33%如果文档里有几十张高清图生成的 HTML 可能达到几十 MB浏览器渲染会卡。我一般会在图片超过 10 张时自动切换到独立文件模式。4. 避坑与排查那些让我加班到凌晨的报错4.1 现象读.doc报OfficeXmlFileException原因用XWPFDocument去构造.doc文件。.doc是二进制格式不是 OOXML。解决在入口处根据扩展名或文件头判断.doc走HWPFDocument。文件头判断可以读前 8 个字节.doc的魔数是D0 CF 11 E0。4.2 现象生成的 HTML 中文乱码原因输出流没有指定 UTF-8 编码或者 HTMLhead里缺少meta charsetutf-8。解决在HtmlRenderer里统一用StandardCharsets.UTF_8写文件并在 HTML 模板里固定加上 charset 声明。4.3 现象WPS 生成的.docx样式名和 Office 不一样原因WPS 可能把标题样式命名为1、2或者中文“标题 1”。解决不要只匹配Heading前缀同时判断字号。比如字号大于 16pt 且加粗的段落可以按h2处理。这个规则需要根据实际文档样本调没有万能公式。4.4 现象表格列数对不上页面错位原因合并单元格导致getTableCells()返回的列数和视觉列数不一致。解决用getGridSpan()补colspan同时以第一行或最大列数为基准生成表格。如果表格特别复杂建议先转成二维数组再渲染。4.5 现象图片丢失或显示裂图原因getAllPictures()拿到的图片没有和段落里的引用关联或者 base64 的 MIME 类型写错。解决遍历paragraph.getRuns()通过run.getEmbeddedPictures()拿到图片这样能保证图片出现在正确的位置。MIME 类型直接用picture.getPictureTypeEnum().getContentType()不要手写。5. 二次开发把转换器改成可配置的流水线如果你已经跑通了基础版本下一步大概率会遇到这些需求有的文档要保留样式有的只要纯文本有的图片要内嵌有的要上传到对象存储。这时候把转换逻辑写死在Converter里就不合适了我一般会把它改成一条可配置的流水线。// 转换配置控制输出行为 public class ConvertOptions { private boolean keepStyle true; // 是否保留标题/加粗 private boolean inlineImage true; // 图片是否 base64 内嵌 private int maxInlineImageCount 10; // 超过则转独立文件 private boolean escapeHtml true; // 是否转义 HTML // getter/setter 省略 } // 在转换入口根据配置决定行为 public String convert(InputStream in, String fileName, ConvertOptions options) { DocumentParser parser getParser(fileName); ListDocumentElement elements parser.parse(in); HtmlRenderer renderer new HtmlRenderer(options); return renderer.render(elements); }这样改的好处是前端传不同的ConvertOptions后端就能输出不同形态的 HTML。比如做内容提取时把keepStyle设为false只输出纯文本段落做在线预览时把keepStyle和inlineImage都设为true。验证方法也很直接准备三份测试文档——一份纯文字、一份带表格和图片、一份 WPS 生成的.doc。每次改完转换逻辑跑一遍这三份文档对比输出的 HTML 在浏览器里的渲染效果。我习惯把这三份文档放在src/test/resources下写一个 JUnit 测试断言输出里包含预期的h1、table和img标签。还有一个容易忽略的点POI 解析大文档时内存占用很高。如果文档超过 50 页建议用XWPFDocument的流式读取或者把转换任务放到独立线程池里避免阻塞主请求。我一般会限制上传文件大小在 10MB 以内超过就提示用户拆分。从那以后我每次改转换逻辑都强制走一遍“三份文档 浏览器渲染”的验证流程不再只靠单元测试断言字符串。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站