婉溪 發表於 2025-11-5 08:17:00

2. AI 输出内容导出Word!docx4j+poi-tl 实现Markdown转Word全流程

<h2 id="1简介">1.简介</h2>
<p>我们在上一章介绍了如果想实现将<code>markdown</code>内容转换为<code>word</code>的话, 如果想要转换后的<code>word</code>内容排版好看的话, 就需要将其转换过程分为两步</p>
<ol>
<li><code>markdown</code>→<code>html</code></li>
<li><code>html</code>→<code>ooxml(Office Open XML) word内容,word元信息本身就是个xml)</code></li>
</ol>
<p>上一章节我们使用<code>flexmark</code>将<code>markdown</code>内容转换为<code>html</code>内容, 完成了第一步, 本章节我们将介绍如何将<code>html</code>转换为<code>ooxml</code></p>
<h2 id="2-环境信息">2. 环境信息</h2>
<p>为了兼容更多的场景, 所以并没有用一些高版本的SDK, 信息如下</p>
<pre><code>Java: 8
Docx4j: 8.3.10
</code></pre>
<h2 id="3-maven">3. Maven</h2>
<pre><code class="language-xml">&lt;properties&gt;
&lt;docx4j.version&gt;8.3.10&lt;/docx4j.version&gt;
&lt;jaxb2.version&gt;1.11.1&lt;/jaxb2.version&gt;
&lt;/properties&gt;

&lt;dependencies&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.docx4j&lt;/groupId&gt;
    &lt;artifactId&gt;docx4j-JAXB-Internal&lt;/artifactId&gt;
    &lt;version&gt;${docx4j.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.docx4j&lt;/groupId&gt;
    &lt;artifactId&gt;docx4j-ImportXHTML&lt;/artifactId&gt;
    &lt;version&gt;${docx4j.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.docx4j&lt;/groupId&gt;
    &lt;artifactId&gt;docx4j-JAXB-MOXy&lt;/artifactId&gt;
    &lt;version&gt;${docx4j.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.jvnet.jaxb2_commons&lt;/groupId&gt;
    &lt;artifactId&gt;jaxb2-basics&lt;/artifactId&gt;
    &lt;version&gt;${jaxb2.version}&lt;/version&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;
</code></pre>
<h2 id="4-html转docx">4. Html转Docx</h2>
<pre><code class="language-java">import lombok.SneakyThrows;
import org.docx4j.Docx4J;
import org.docx4j.convert.in.xhtml.XHTMLImporterImpl;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart;
import org.docx4j.wml.Body;

import java.io.File;

/**
* html 2 docx
*
* @author ludangxin
* @since 2025/10/14
*/
public class HtmlToDocx {
    @SneakyThrows
    public static void convertHtmlToDocx(String htmlContent, String outputFilePath) {
      // 创建 Word 文档包
      WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.createPackage();
      MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart();
      // 设置 XHTML 导入器
      XHTMLImporterImpl XHTMLImporter = new XHTMLImporterImpl(wordMLPackage);
      // 将 HTML 内容导入到 Word 文档中
      Body body = mainDocumentPart.getJaxbElement().getBody();
      body.getContent().addAll(XHTMLImporter.convert(htmlContent, null));
      // 保存 Word 文档
      Docx4J.save(wordMLPackage, new File(outputFilePath), Docx4J.FLAG_NONE);
    }

    public static void main(String[] args) {
      String html = "&lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;h2&gt;嘉文四世&lt;/h2&gt;\n" + "&lt;blockquote&gt;\n" + "&lt;p&gt;德玛西亚&lt;/p&gt;\n" + "&lt;/blockquote&gt;\n" + "&lt;p&gt;&lt;strong&gt;给我找些更强的敌人!&lt;/strong&gt;&lt;/p&gt;\n" + "&lt;table&gt;\n" + "&lt;thead&gt;\n" + "&lt;tr&gt;&lt;th&gt;列1&lt;/th&gt;&lt;th&gt;列2&lt;/th&gt;&lt;/tr&gt;\n" + "&lt;/thead&gt;\n" + "&lt;tbody&gt;\n" + "&lt;tr&gt;&lt;td&gt;数据1&lt;/td&gt;&lt;td&gt;数据2&lt;/td&gt;&lt;/tr&gt;\n" + "&lt;/tbody&gt;\n" + "&lt;/table&gt;\n" + "&lt;/body&gt;&lt;/html&gt;";
      convertHtmlToDocx(html, "demo.docx");
    }
}
</code></pre>
<blockquote>
<p>测试结果如下:</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162607693-1472241397.png" alt="image-20251104094513921" width="264" height="94" loading="lazy"></p>
<p>在根目录生成了docx文件, 文件内容如下</p>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162658391-1043815044.png" alt="image-20251104095454439" width="517" height="512" loading="lazy"></p>
<p>生成的文档内容是有样式的, 只不过像字体样式, 包括表格都是默认样式</p>
<p>但如果项目上要求输出的制式文档或者是模板文件, 对文字标题甚至是表格都有其样式要求的话, 那就得用一些高阶用法了</p>
<h2 id="5-自定义样式">5. 自定义样式</h2>
<p>如果想要自定义输出的内容样式, 其实就两个思路:</p>
<ol>
<li>从输出的内容出发: 毕竟是html转的ooxml, 那么可以给html添加css样式给docx4j进行渲染, 但前提是一些简单的css样式</li>
<li>从word出发: word文件本身就有内置样式并且也可以自定义样式, 所以可以先在模板文件中定义好样式, 然后和输入的内容进行映射</li>
</ol>
<h3 id="51-html添加css">5.1 Html添加Css</h3>
<p>比如给表格添加样式, 让表格有边框并且有一定的样式,css样式如下:</p>
<blockquote>
<pre><code>table{border-collapse:collapse;border-spacing:0;width:100%;margin:1em 0;background-color:transparent;}table th{background-color:#f7f7f7;border:1px solid #ddd;padding:8px 12px;text-align:left}table td{border:1px solid #ddd;padding:8px 12px}
</code></pre>
</blockquote>
<pre><code class="language-java">public static void main(String[] args) {
    String html = "&lt;html&gt;&lt;head&gt;&lt;style&gt;table{border-collapse:collapse;border-spacing:0;width:100%;margin:1em 0;background-color:transparent;}table th{background-color:#f7f7f7;border:1px solid #ddd;padding:8px 12px;text-align:left}table td{border:1px solid #ddd;padding:8px 12px}&lt;/style&gt;&lt;/head&gt;&lt;body&gt;&lt;h2&gt;嘉文四世&lt;/h2&gt;\n" + "&lt;blockquote&gt;\n" + "&lt;p&gt;德玛西亚&lt;/p&gt;\n" + "&lt;/blockquote&gt;\n" + "&lt;p&gt;&lt;strong&gt;给我找些更强的敌人!&lt;/strong&gt;&lt;/p&gt;\n" + "&lt;table&gt;\n" + "&lt;thead&gt;\n" + "&lt;tr&gt;&lt;th&gt;列1&lt;/th&gt;&lt;th&gt;列2&lt;/th&gt;&lt;/tr&gt;\n" + "&lt;/thead&gt;\n" + "&lt;tbody&gt;\n" + "&lt;tr&gt;&lt;td&gt;数据1&lt;/td&gt;&lt;td&gt;数据2&lt;/td&gt;&lt;/tr&gt;\n" + "&lt;/tbody&gt;\n" + "&lt;/table&gt;\n" + "&lt;/body&gt;&lt;/html&gt;";
    convertHtmlToDocx(html, "demo.docx");
}
</code></pre>
<blockquote>
<p>测试结果如下:</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162734462-1446548075.png" alt="image-20251104101836103" width="783" height="526" loading="lazy"></p>
<p>此时其实如果想要输出的内容样式好看, 通过定义css基本可以满足了, 但如果是制式文档对行间距,字间距,字体型号,标题,等有严格的要求, 如果这些都通过css定义的话 有点麻烦, 毕竟人家制式的文档本身已经定义好了, 那么就可以使用下面的方式</p>
<h3 id="52-html映射wordstyleid">5.2 Html映射WordStyleId</h3>
<p>我们可以先看一下word的内置样式, 我这里使用的是mac office,windows 和 wps 有些许差异</p>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162809599-2050174202.png" alt="image-20251104102955668" width="781" height="740" loading="lazy"></p>
<p>从上图中可以看到, word其实是有很多内置样式的, 并且可以新建样式, 下面也可以筛选列表</p>
<p>我们经常在快捷样式列表中选择的样式其实就是从这里来的</p>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162827807-1605880728.png" alt="image-20251104103219913" width="909" height="778" loading="lazy"></p>
<p>我们先手动新增一个自定义的样式 如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162845107-128756711.png" alt="image-20251104110959200" width="636" height="655" loading="lazy"></p>
<p>然后通过docx4j获取所有的wordstyle列表 如下:</p>
<pre><code class="language-java">private static WordprocessingMLPackage wordMLPackage;

@BeforeAll
@SneakyThrows
public static void init_mainDocumentPart() {
        File templateFile = new File("demo.docx");
        wordMLPackage = WordprocessingMLPackage.load(templateFile);
}

@Test
@SneakyThrows
public void given_doc_template_when_extract_style_then_return_style_list() {
        final StyleDefinitionsPart sdp = wordMLPackage.getMainDocumentPart().getStyleDefinitionsPart();
        List&lt;Style&gt; styles = sdp.getContents().getStyle();
        log.info("docx styles length: {}", styles.size());
        for (Style style : styles) {
          String styleId = style.getStyleId();
          String name = style.getName().getVal();
          final String type = style.getType();
          log.info("styleId: {}, name: {}, type: {}", styleId, name, type);
        }
}
</code></pre>
<blockquote>
<p>测试结果如下: 除了内置的样式如一级标题id=1, 最后的两个自定义样式是我们新加的</p>
<p>为什么手动添加了一个, 而出现两个样式记录: 可能是在选在样式类型的时候选择的是“链接段落和字符”导致出现了一对多的情况</p>
</blockquote>
<pre><code> INFO html2docx.DocxStyleTest -- docx styles length: 25
INFO html2docx.DocxStyleTest -- styleId: a, name: Normal, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: 1, name: heading 1, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: 2, name: heading 2, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: 3, name: heading 3, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: 4, name: heading 4, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: a0, name: Default Paragraph Font, type: character
INFO html2docx.DocxStyleTest -- styleId: a1, name: Normal Table, type: table
INFO html2docx.DocxStyleTest -- styleId: a2, name: No List, type: numbering
INFO html2docx.DocxStyleTest -- styleId: a3, name: header, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: a4, name: 页眉 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: 10, name: 标题 1 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: 20, name: 标题 2 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: 30, name: 标题 3 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: 40, name: 标题 4 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: a5, name: Normal Indent, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: a6, name: Subtitle, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: a7, name: 副标题 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: a8, name: Title, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: a9, name: 标题 字符, type: character
INFO html2docx.DocxStyleTest -- styleId: aa, name: Emphasis, type: character
INFO html2docx.DocxStyleTest -- styleId: ab, name: Hyperlink, type: character
INFO html2docx.DocxStyleTest -- styleId: ac, name: Table Grid, type: table
INFO html2docx.DocxStyleTest -- styleId: ad, name: caption, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: customBodyText, name: customBodyText, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: customBodyText0, name: customBodyText 字符, type: character
</code></pre>
<p>测试的时候发现一个奇怪的问题, 如果没有手动添加样式的话输出的内容如下:</p>
<pre><code> INFO html2docx.DocxStyleTest -- docx styles length: 22
INFO html2docx.DocxStyleTest -- styleId: Normal, name: Normal, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: Heading1, name: heading 1, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: Heading2, name: heading 2, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: Heading3, name: heading 3, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: Heading4, name: heading 4, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: DefaultParagraphFont, name: Default Paragraph Font, type: character
INFO html2docx.DocxStyleTest -- styleId: Header, name: header, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: HeaderChar, name: Header Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Heading1Char, name: Heading 1 Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Heading2Char, name: Heading 2 Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Heading3Char, name: Heading 3 Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Heading4Char, name: Heading 4 Char, type: character
INFO html2docx.DocxStyleTest -- styleId: NormalIndent, name: Normal Indent, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: Subtitle, name: Subtitle, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: SubtitleChar, name: Subtitle Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Title, name: Title, type: paragraph
INFO html2docx.DocxStyleTest -- styleId: TitleChar, name: Title Char, type: character
INFO html2docx.DocxStyleTest -- styleId: Emphasis, name: Emphasis, type: character
INFO html2docx.DocxStyleTest -- styleId: Hyperlink, name: Hyperlink, type: character
INFO html2docx.DocxStyleTest -- styleId: TableGrid, name: Table Grid, type: table
INFO html2docx.DocxStyleTest -- styleId: TableNormal, name: Normal Table, type: table
INFO html2docx.DocxStyleTest -- styleId: Caption, name: caption, type: paragraph
</code></pre>
<p>可以发现内置的style前后不一致,未添加自定义样式前一级标题的id为“Heading1”, 修改后就成了“1”, 可能是默认生成的文档还是英文的, 当修改保存了之后, 就被系统篡改成中文的了</p>
<p>ok, word样式我们定义好了</p>
<p>现在就通过docx4j应用一下自定义的word样式, 实现思路: <strong>通过html标签的class属性 映射wrod的styleId</strong></p>
<p>首先给html加上class信息如下图:</p>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162909107-1320293236.png" alt="image-20251104140415430" width="514" height="696" loading="lazy"></p>
<pre><code class="language-java">private static WordprocessingMLPackage wordMLPackage;

@BeforeAll
@SneakyThrows
public static void init_mainDocumentPart() {
        File templateFile = new File("demo.docx");
        wordMLPackage = WordprocessingMLPackage.load(templateFile);
}

@Test
@SneakyThrows
public void given_doc_template_and_class_when_mapping_custom_style_then_render_doc() {
        final String html = "&lt;html&gt;&lt;head&gt;&lt;style&gt;table{border-collapse:collapse;border-spacing:0;width:100%;margin:1em 0;background-color:transparent}table th{background-color:#f7f7f7;border:1px solid#ddd;padding:8px 12px;text-align:left}table td{border:1px solid#ddd;padding:8px 12px}&lt;/style&gt;&lt;/head&gt;&lt;body&gt;&lt;h2 class=\"1\"&gt;嘉文四世&lt;/h2&gt;&lt;blockquote&gt;&lt;p class=\"customBodyText\"&gt;德玛西亚&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=\"customBodyText\"&gt;&lt;strong&gt;给我找些更强的敌人!&lt;/strong&gt;&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;列1&lt;/th&gt;&lt;th&gt;列2&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;数据1&lt;/td&gt;&lt;td&gt;数据2&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/body&gt;&lt;/html&gt;";
        final MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart();
        XHTMLImporterImpl importer = new XHTMLImporterImpl(wordMLPackage);
        // CLASS_TO_STYLE_ONLY:只认 class,不管 style 和 &lt;strong&gt;/&lt;em&gt; 等标签,相当于「纯 CSS 类驱动样式」
        // CLASS_PLUS_OTHER:class 是基础样式,style 和内联标签是补充 / 覆盖,相当于「类样式 + 局部微调样式」
        // IGNORE_CLASS: 忽略class样式
        importer.setParagraphFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
        importer.setRunFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
        importer.setTableFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
        // html转ooxml
        final List&lt;Object&gt; docxContent = importer.convert(html, null);
        final List&lt;Object&gt; docxOldContent = mainDocumentPart.getContent();
        // 清空模板内容 并 添加新的内容
        docxOldContent.clear();
        docxOldContent.addAll(docxContent);
        Docx4J.save(wordMLPackage, new File("newDemo.docx"), Docx4J.FLAG_NONE);
}
</code></pre>
<blockquote>
<p>测试结果如下:</p>
<p>一级标题和自定义样式都映射完成了</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162926640-1000769685.png" alt="image-20251104141502094" width="1155" height="534" loading="lazy"></p>
<h2 id="6-占位符替换">6. 占位符替换</h2>
<p>结合上述的功能, 已经能很好的输出html到word中了, 但项目上又有新的需求了, 不光是将markdown→html→word, 还需要将大模型识别到的内容一同输出到模板文件中去, 也就是最终输出的内容有两部分</p>
<ol>
<li>大模型提取到的人员信息, 如姓名工作住址等</li>
<li>大模型总结的人员描述信息(markdown)</li>
</ol>
<p>其实第二步内容使用doc4j已经实现了, 现在需要通过占位符的方式输出人员基本信息到word中, 这个其实很好处理, 可以使用<code>poi-tl</code>实现占位符的替换</p>
<h3 id="61-maven">6.1 Maven</h3>
<pre><code class="language-xml">&lt;dependency&gt;
        &lt;groupId&gt;com.deepoove&lt;/groupId&gt;
        &lt;artifactId&gt;poi-tl&lt;/artifactId&gt;
        &lt;version&gt;1.12.0&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<h3 id="62-实现">6.2 实现</h3>
<pre><code class="language-java">@Test
public void given_template_doc_and_content_when_replace_then_replace() {
    final Configure templateEngineConfigure = Configure.builder().build();
    File templateFile = new File("demo.docx");
    File outputFile = new File("newDemo.docx");
    Map&lt;String, Object&gt; data = new HashMap&lt;&gt;();
    data.put("user", "嘉文四世");
        data.put("summoner", "张铁牛");
    data.put("position", "打野");
    data.put("dialogue", "给我找些更强的敌人");
    try (XWPFTemplate template = XWPFTemplate.compile(templateFile, templateEngineConfigure)) {
            template.render(data).writeToFile(outputFile.getAbsolutePath());
      }
      catch (IOException e) {
            log.error("failed to replace template word placeholder", e);
            throw new RuntimeException(e);
      }
}
</code></pre>
<blockquote>
<p>模板内容如下:</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162943367-689162054.png" alt="image-20251104153401522" width="541" height="434" loading="lazy"></p>
<blockquote>
<p>测试结果如下:</p>
<p>不仅实现了占位符的替换, 而且也保留了占位符本身的样式, 这就很舒服了</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104162959954-1788437758.png" alt="image-20251104153503042" width="515" height="477" loading="lazy"></p>
<h2 id="7-封装工具类">7. 封装工具类</h2>
<p>为了更方便的调用docx4j和poi-tl操作word, 我们可以封装一个工具类去更方便的调用, 比如可以通过传入一个map对象然后实现自动替换占位符和markdown内容渲染, 最好是通过链式调用一行代码就解决战斗, 没错 它来了</p>
<pre><code class="language-java">import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.docx4j.convert.in.xhtml.FormattingOption;
import org.docx4j.convert.in.xhtml.XHTMLImporterImpl;
import org.docx4j.openpackaging.exceptions.Docx4JException;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart;
import org.docx4j.wml.Body;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;

/**
* doc操作工具类
*
* @author ludangxin
* @since 2025/10/14
*/
@Slf4j
public class Docs {
    @SneakyThrows
    public static DocBuilder builder() {
      return new DocBuilder().wordMLPackage(WordprocessingMLPackage.createPackage());
    }

    @SneakyThrows
    public static DocBuilder builder(File file) {
      return new DocBuilder().templateInputStream(Files.newInputStream(file.toPath()))
                               .wordMLPackage(WordprocessingMLPackage.load(file));
    }

    @SneakyThrows
    public static DocBuilder builder(InputStream inputStream) {
      return new DocBuilder().templateInputStream(inputStream)
                               .wordMLPackage(WordprocessingMLPackage.load(inputStream));
    }

    @SneakyThrows
    public static DocBuilder builder(String filePath) {
      return new DocBuilder().templateInputStream(Files.newInputStream(new File(filePath).toPath()))
                               .wordMLPackage(WordprocessingMLPackage.load(new File(filePath)));
    }

    public static class DocBuilder {
      private InputStream templateInputStream;

      private WordprocessingMLPackage wordMLPackage;

      private XHTMLImporterImpl importer;

      private FormattingOption paragraphFormatting;

      private FormattingOption runFormatting;

      private FormattingOption tableFormatting;

      private String staticResourceBaseUri;

      private String[] placeHolderPreSuffix = new String[]{"{{", "}}"};

      private Configure templateEngineConfigure;

      private boolean useHtmlDefaultStyle = true;

      private boolean autoCloseStream = true;

      private String globalCss = "table{border-collapse:collapse;border-spacing:0;width:100%;margin:1em 0;background-color:transparent;}table th{background-color:#f7f7f7;border:1px solid #ddd;padding:8px 12px;text-align:left}table td{border:1px solid #ddd;padding:8px 12px}";

      /**
         * &lt;String, String, String&gt;: htmlContent htmlKey resultHtmlContent
         */
      private BiFunction&lt;String, String, String&gt; htmlContentProcessor;

      private DocBuilder templateInputStream(InputStream templateInputStream) {
            this.templateInputStream = templateInputStream;
            return this;
      }

      private DocBuilder wordMLPackage(WordprocessingMLPackage wordMLPackage) {
            this.wordMLPackage = wordMLPackage;
            return this;
      }

      public DocBuilder importer(XHTMLImporterImpl importer) {
            this.importer = importer;
            return this;
      }

      public DocBuilder paragraphFormatting(FormattingOption paragraphFormatting) {
            this.paragraphFormatting = paragraphFormatting;
            return this;
      }

      public DocBuilder runFormatting(FormattingOption runFormatting) {
            this.runFormatting = runFormatting;
            return this;
      }

      public DocBuilder tableFormatting(FormattingOption tableFormatting) {
            this.tableFormatting = tableFormatting;
            return this;
      }

      public DocBuilder useHtmlDefaultStyle(boolean useHtmlDefaultStyle) {
            this.useHtmlDefaultStyle = useHtmlDefaultStyle;
            return this;
      }

      public DocBuilder staticResourceBaseUri(String staticResourceBaseUri) {
            this.staticResourceBaseUri = staticResourceBaseUri;
            return this;
      }

      public DocBuilder placeHolderPreSuffix(String placeHolderPrefix, String placeHolderSuffix) {
            this.placeHolderPreSuffix = new String[]{placeHolderPrefix, placeHolderSuffix};
            return this;
      }

      public DocBuilder templateEngineConfigure(Configure templateEngineConfigure) {
            this.templateEngineConfigure = templateEngineConfigure;
            return this;
      }

      public DocBuilder autoCloseStream(boolean autoCloseStream) {
            this.autoCloseStream = autoCloseStream;
            return this;
      }

      public DocBuilder globalCss(String globalCss) {
            this.globalCss = globalCss;
            return this;
      }

      public DocBuilder htmlContentProcessor(BiFunction&lt;String, String, String&gt; htmlContentProcessor) {
            this.htmlContentProcessor = htmlContentProcessor;
            return this;
      }

      public List&lt;Object&gt; buildWordML(String html) {
            return this.buildWordML(html, null);
      }

      public void buildWord(String html, String outputFile) {
            this.buildWord(html, new File(outputFile));
      }

      public void buildWord(String html, File outputFile) {
            try {
                this.getMainContent()
                  .addAll(this.buildWordML(html));
                wordMLPackage.save(outputFile);
            }
            catch (Exception e) {
                log.error("failed to build word file", e);
                throw new RuntimeException(e);
            }
      }

      public void buildWord(String html, OutputStream outputStream) {
            try {
                this.getMainContent()
                  .addAll(this.buildWordML(html));
                wordMLPackage.save(outputStream);
            }
            catch (Exception e) {
                log.error("failed to build word file", e);
                throw new RuntimeException(e);
            }
            finally {
                try {
                  if (autoCloseStream) {
                        outputStream.close();
                  }
                }
                catch (IOException ignored) {
                }
            }
      }

      public void buildWord(Map&lt;String, Object&gt; placeHolderData, OutputStream outputStream) {
            try {
                // 替换模板中的普通占位符
                if (this.checkPlaceHolderDataType(placeHolderData) == 1) {
                  this.replacePlaceHolder(placeHolderData, outputStream);
                }

                // 替换模板中包含的html
                if (this.checkPlaceHolderDataType(placeHolderData) == 2) {
                  this.replaceHtmlPlaceHolder(placeHolderData, outputStream);
                }

                // 替换普通/html占位符
                if (this.checkPlaceHolderDataType(placeHolderData) == 3) {
                  final File tempDocFile = DocUtils.createTempDocFile();
                  this.replaceHtmlPlaceHolder(placeHolderData, tempDocFile);
                  this.replacePlaceHolder(placeHolderData, tempDocFile, tempDocFile);
                  DocUtils.writeAndDeleteFile(tempDocFile, outputStream);
                }
            }
            catch (Exception e) {
                log.error("failed to build word file", e);
                throw new RuntimeException(e);
            }
            finally {
                try {
                  if (autoCloseStream) {
                        outputStream.close();
                  }
                }
                catch (IOException ignored) {
                }
            }
      }

      public void buildWord(Map&lt;String, Object&gt; placeHolderData, File outputFile) {
            // 替换模板中的普通占位符
            if (this.checkPlaceHolderDataType(placeHolderData) == 1) {
                this.replacePlaceHolder(placeHolderData, outputFile);
            }

            // 替换模板中包含的html
            if (this.checkPlaceHolderDataType(placeHolderData) &gt; 1) {
                this.replaceHtmlPlaceHolder(placeHolderData, outputFile);
            }

            // 追加替换普通占位符
            if (this.checkPlaceHolderDataType(placeHolderData) == 3) {
                this.replacePlaceHolder(placeHolderData, outputFile, outputFile);
            }
      }

      private List&lt;Object&gt; buildWordML(String html, String htmlKey) {
            final XHTMLImporterImpl importer = this.getImporterOrDefault();
            try {
                if (globalCss != null &amp;&amp; !globalCss.isEmpty()) {
                  html = DocUtils.addHtmlStyles(html, globalCss);
                }

                if (htmlContentProcessor != null) {
                  html = htmlContentProcessor.apply(html, htmlKey);
                }

                return importer.convert(html, staticResourceBaseUri);
            }
            catch (Exception e) {
                log.error("failed to convert HTML to XHTML", e);
                throw new RuntimeException(e);
            }
      }

      private void replaceHtmlPlaceHolder(Map&lt;String, Object&gt; placeHolderData, File outputFile) {
            this.doReplaceHtmlPlaceHolder(placeHolderData);

            try {
                // 替换html
                wordMLPackage.save(outputFile);
            }
            catch (Docx4JException e) {
                log.error("failed to build word file", e);
                throw new RuntimeException(e);
            }
      }

      private void replaceHtmlPlaceHolder(Map&lt;String, Object&gt; placeHolderData, OutputStream outputStream) {
            this.doReplaceHtmlPlaceHolder(placeHolderData);

            try {
                // 替换html
                wordMLPackage.save(outputStream);
            }
            catch (Docx4JException e) {
                log.error("failed to build word file", e);
                throw new RuntimeException(e);
            }
            finally {
                try {
                  if (autoCloseStream) {
                        outputStream.close();
                  }
                }
                catch (IOException ignored) {
                }
            }
      }

      private void doReplaceHtmlPlaceHolder(Map&lt;String, Object&gt; placeHolderData) {
            final List&lt;Object&gt; mainContent = this.getMainContent();
            List&lt;Object&gt; newContent = new ArrayList&lt;&gt;();

            for (Object p : mainContent) {
                String text = DocUtils.extractText(p);

                Optional&lt;String&gt; matchedKey = placeHolderData.keySet()
                                                             .stream()
                                                             .filter(key -&gt; DocUtils.matchPlaceHolder(text, key, placeHolderPreSuffix, placeHolderPreSuffix))
                                                             .findFirst();

                if (matchedKey.isPresent()) {
                  String key = matchedKey.get();
                  Object value = placeHolderData.get(key);

                  if (DocUtils.isHtml(value)) {
                        final List&lt;Object&gt; wordFragment = this.buildWordML((String) value, key);
                        newContent.addAll(wordFragment);
                  }
                  else {
                        newContent.add(p);
                  }
                }
                else {
                  newContent.add(p);
                }
            }

            // 替换模板内容
            mainContent.clear();
            mainContent.addAll(newContent);
      }

      private void replacePlaceHolder(Map&lt;String, Object&gt; data, File templateFile, File outputFile) {
            final Configure templateEngineConfigure = this.getTemplateEngineConfigureOrDefault();
            try (XWPFTemplate template = XWPFTemplate.compile(templateFile, templateEngineConfigure)){
                template.render(data)
                        .writeToFile(outputFile.getAbsolutePath());
            }
            catch (IOException e) {
                log.error("failed to replace template word placeholder", e);
                throw new RuntimeException(e);
            }
      }

      public void replacePlaceHolder(Map&lt;String, Object&gt; data, File outputFile) {
            final Configure templateEngineConfigure = this.getTemplateEngineConfigureOrDefault();

            if (templateInputStream == null) {
                throw new NullPointerException("template file can not be null");
            }

            XWPFTemplate template = XWPFTemplate.compile(templateInputStream, templateEngineConfigure);
            try {
                template.render(data)
                        .writeToFile(outputFile.getAbsolutePath());
            }
            catch (IOException e) {
                log.error("failed to replace template word placeholder", e);
                throw new RuntimeException(e);
            }
      }

      public void replacePlaceHolder(Map&lt;String, Object&gt; data, String outputFileAbsolutePath) {
            final Configure templateEngineConfigure = this.getTemplateEngineConfigureOrDefault();

            if (templateInputStream == null) {
                throw new NullPointerException("template file can not be null");
            }

            XWPFTemplate template = XWPFTemplate.compile(templateInputStream, templateEngineConfigure);
            try {
                template.render(data)
                        .writeToFile(outputFileAbsolutePath);
            }
            catch (IOException e) {
                log.error("failed to replace template word placeholder", e);
                throw new RuntimeException(e);
            }
      }

      public void replacePlaceHolder(Map&lt;String, Object&gt; data, OutputStream outputStream) {
            final Configure templateEngineConfigure = this.getTemplateEngineConfigureOrDefault();

            if (templateInputStream == null) {
                throw new NullPointerException("template file can not be null");
            }

            try {
                XWPFTemplate template = XWPFTemplate.compile(templateInputStream, templateEngineConfigure);
                final XWPFTemplate render = template.render(data);
                render.write(outputStream);
            }
            catch (IOException e) {
                log.error("failed to replace template word placeholder", e);
                throw new RuntimeException(e);
            }
            finally {
                try {
                  if (autoCloseStream) {
                        outputStream.close();
                  }
                }
                catch (IOException ignored) {
                }
            }
      }

      private XHTMLImporterImpl getImporterOrDefault() {
            if (importer == null) {
                if (paragraphFormatting != null || runFormatting != null || tableFormatting != null) {
                  XHTMLImporterImpl importer = new XHTMLImporterImpl(wordMLPackage);
                  importer.setParagraphFormatting(paragraphFormatting == null ? FormattingOption.CLASS_PLUS_OTHER : paragraphFormatting);
                  importer.setRunFormatting(runFormatting == null ? FormattingOption.CLASS_PLUS_OTHER : runFormatting);
                  importer.setTableFormatting(tableFormatting == null ? FormattingOption.CLASS_PLUS_OTHER : tableFormatting);

                  return importer;
                }
                else {
                  return this.defaultImporter();
                }
            }
            else {
                return this.importer;
            }
      }

      private Configure getTemplateEngineConfigureOrDefault() {
            if (templateEngineConfigure == null) {
                return this.defaultTemplateEngineConfigure();
            }
            else {
                return this.templateEngineConfigure;
            }
      }

      private Configure defaultTemplateEngineConfigure() {
            return Configure.builder()
                            .buildGramer(placeHolderPreSuffix, placeHolderPreSuffix)
                            .build();
      }

      private XHTMLImporterImpl defaultImporter() {
            XHTMLImporterImpl importer = new XHTMLImporterImpl(wordMLPackage);
            if (useHtmlDefaultStyle) {
                importer.setParagraphFormatting(FormattingOption.CLASS_PLUS_OTHER);
                importer.setRunFormatting(FormattingOption.CLASS_PLUS_OTHER);
                importer.setTableFormatting(FormattingOption.CLASS_PLUS_OTHER);
            }
            else {
                importer.setParagraphFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
                importer.setRunFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
                importer.setTableFormatting(FormattingOption.CLASS_TO_STYLE_ONLY);
            }

            return importer;
      }

      private List&lt;Object&gt; getMainContent() {
            MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart();
            if (globalCss != null &amp;&amp; !globalCss.isEmpty()) {
                mainDocumentPart.getStyleDefinitionsPart()
                              .setCss(globalCss);
            }
            Body body = mainDocumentPart.getJaxbElement()
                                        .getBody();
            return body.getContent();
      }

      /**
         * 判断占位符数据类型
         *
         * @param placeHolderData 占位符数据
         * @return 1: 数据不包含html 2: 数据全是html 3: 都包含
         */
      private int checkPlaceHolderDataType(Map&lt;String, Object&gt; placeHolderData) {
            boolean hasHtmlValFlag = false;
            boolean hasCommonValFlag = false;

            for (Object value : placeHolderData.values()) {
                if (DocUtils.isHtml(value)) {
                  hasHtmlValFlag = true;
                }
                else {
                  hasCommonValFlag = true;
                }
            }

            if (!hasHtmlValFlag &amp;&amp; hasCommonValFlag) {
                return 1;
            }

            if (hasHtmlValFlag &amp;&amp; !hasCommonValFlag) {
                return 2;
            }

            return 3;
      }
    }
}
</code></pre>
<h2 id="8-测试示例">8. 测试示例</h2>
<pre><code class="language-java">import lombok.SneakyThrows;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.io.File;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

/**
* docs test
*
* @author ludangxin
* @since 2025/11/4
*/
public class DocxTest {
    private static final File TEMPLATE_FILE = new File("demo.docx");
    private static final File OUTPUT_FILE = new File("output.docx");
    private static final Map&lt;String, Object&gt; DATA = new HashMap&lt;&gt;();

    @BeforeAll
    public static void given_data() {
      DATA.put("user", "嘉文四世");
      DATA.put("summoner", "张铁牛");
      DATA.put("position", "打野");
      DATA.put("dialogue", "给我找些更强的敌人");
      final String markdownContent = "- **背景故事**:嘉文四世是德玛西亚国王嘉文三世的独生子,其母凯瑟琳女士因难产而死。嘉文在宫廷中长大,接受了良好的德玛西亚式教育,并结识了赵信,向其学习战争艺术。他与盖伦年龄相仿,结为好兄弟。嘉文曾率军前往边境对抗诺克萨斯,却因战力分散而战败,幸得希瓦娜相救。后来,德玛西亚国内搜魔人兵团搜捕魔法师引发起义,嘉文三世惨遭弑杀,嘉文四世接掌了议会,之后他登基成为德玛西亚国王。\n" + "\n" + "- **角色定位**:在游戏中,嘉文四世的定位是坦克、战士,他常常需要带头冲入敌方阵地,因此相比输出更加需要增强防御能力。\n" + "\n" + "- 技能介绍\n" + ":\n" + "- **被动技能 - 战争律动**:普攻命中时,会对目标造成 8% 当前生命值的额外物理伤害,该效果作用于同一目标的冷却时间为 6 秒。\n" + "- **一技能 - 巨龙撞击**:用长矛穿透路径上的敌人,对其造成物理伤害,并减少其护甲,持续 3 秒。若长矛触及 “德邦军旗”,嘉文四世会被引向军旗,并击飞沿途敌人 0.75 秒。\n" + "- **二技能 - 黄金圣盾**:释放出一道帝王光环,使周围敌人减速,持续 2 秒,同时提供一个可以吸收伤害的护盾,持续 5 秒,附近每多一名敌方英雄,吸收伤害增加。\n" + "- **三技能 - 德邦军旗**:投掷一柄军旗,对敌人造成魔法伤害,并将军旗置于原地 8 秒,使附近队友获得攻击速度加成。在 “德邦军旗” 附近再次点击施放该技能,将会朝军旗施放 “巨龙撞击”。\n" + "- **终极技能 - 天崩地裂**:跃向敌方英雄,对目标及其附近的敌人造成物理伤害,并在目标周围形成环形障碍,持续 3.5 秒,再次点击施放可使障碍倒塌。\n" + "\n" + "- **皮肤信息**:嘉文四世拥有多款皮肤,包括孤胆英豪、暗星、福牛守护者等。";
      // markdown 2 html (上一章博客的内容)
      final String htmlContent = Markdowns.builder(markdownContent)
                                          .buildHtmlContent();
      DATA.put("description", htmlContent);
    }

    @Test
    public void given_template_doc_and_content_when_replace_then_complete() {
      Docs.builder(TEMPLATE_FILE).buildWord(DATA, OUTPUT_FILE);
    }

    @Test
    @SneakyThrows
    public void given_template_doc_and_content_when_replace_and_output_stream_then_complete() {
      final OutputStream fileOutputStream = Files.newOutputStream(OUTPUT_FILE.toPath());
      // 接收输出流
      Docs.builder(TEMPLATE_FILE).autoCloseStream(true).buildWord(DATA, fileOutputStream);
    }
}
</code></pre>
<blockquote>
<p>模板内容如下:</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104163015414-1316303230.png" alt="image-20251104151925516" width="545" height="441" loading="lazy"></p>
<blockquote>
<p>测试结果如下:</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/blog/1759273/202511/1759273-20251104163032157-76672240.png" alt="image-20251104152052595" width="878" height="808" loading="lazy"></p>
<h2 id="9-小结">9. 小结</h2>
<p>本章使用<code>docx4j</code>和<code>poi-tl</code>实现将普通占位符内容和html文本内容转换为word, 并介绍了如何使用其特性实现自定义样式渲染, 最后封装链式调用的工具类和对应的单元测试代码, 结合上一章内容能够将各种形式的内容通过一行代码即可实现word的渲染</p>
<h2 id="10-源码">10. 源码</h2>
<p>测试过程中的代码已全部上传至github, 欢迎点赞收藏 仓库地址: https://github.com/ludangxin/markdown2docx</p><br><br>
来源:https://www.cnblogs.com/ludangxin/p/19190706
頁: [1]
查看完整版本: 2. AI 输出内容导出Word!docx4j+poi-tl 实现Markdown转Word全流程