基于Java的Markdown转Word工具(标题、段落、表格、Echarts图等)

项目源于我们开发的一款基于大模型的报告生成工具。由于需要将 Markdown 格式的内容导出为 Word 文档,而市面上缺乏合适的现成工具,所以决定自己开发一个Markdown转Word的工具。

🩷源码地址:daydayup-zyn/md2doc-plus

😀实现思路

md2doc-plus 基于 Java 17 和 Apache POI 构建,采用模块化设计,主要包括以下几个核心组件:

  1. Markdown 解析器:负责解析 Markdown 内容,识别文本、表格、图表等元素
  2. 文档生成器:使用 Apache POI 创建和操作 Word 文档
  3. 模板引擎:支持动态生成文档模板,便于后续内容替换
  4. 图表转换器:专门处理 ECharts 图表到 Word 图表的转换

😃核心转换流程如下:

  • 解析 Markdown 内容,识别各种元素(标题、段落、表格、图表等)
  • 基于 Markdown 结构,自动化创建 Word 文档模板,为动态内容预留占位符
  • 将解析后的内容填充到模板中
  • 生成最终的 Word 文档

😁功能亮点

  1. 动态word模板生成

系统能够根据 Markdown 内容结构动态生成 Word 模板,为后续内容填充预留占位符。

  1. Markdown 各级标题与段落文本支持

完整支持 Markdown 的六级标题(H1-H6)以及普通段落文本的转换。系统会自动识别标题级别,并应用相应的格式化样式,包括字体大小、加粗等属性。

  1. Markdown 表格转换

能够准确解析并转换 Markdown 表格语法,将表格内容转换为 Word 中的表格对象,保持原有的行列结构和数据内容。

  1. ECharts图表集成

支持将 Markdown 中的 ECharts 图表配置代码块转换为 Word 中的图表对象,包括自动识别 ECharts 代码块、解析 ECharts 配置并提取图表数据、在 Word 文档中生成对应的图表对象、支持多种图表类型(柱状图、折线图、饼图等)。

echarts 复制代码
{
    title: {
        text: '月度销售数据1'
    },
    tooltip: {
        trigger: 'axis'
    },
    xAxis: {
        type: 'category',
        data: ['1月', '2月', '3月', '4月', '5月', '6月']
    },
    yAxis: {
        type: 'value',
        name: '销售额'
    },
    series: [{
        name: '销售额',
        type: 'line',
        data: [15.32, 15.87, 14.96, 16.23, 13.21, 13.53]
    }]
}
  1. 章节标题自动添加序号、标题格式

具备自动化标题编号功能,能够根据标题层级自动为章节标题添加序号(如 1.1、1.2、2.1 等),并应用标准的标题样式。这使得生成的 Word 文档可以直接用于创建目录,极大地提升了文档的专业性和可用性。

🫠核心实现代码解析

  1. Markdown 解析与文档结构创建

    md2doc-plus 的核心功能之一是解析 Markdown 内容并创建相应的 Word 文档结构。这主要在 DynamicWordDocumentCreator 类中实现:

    java 复制代码
    /**
     * 解析Markdown内容并创建Word文档结构
     * @param document Word文档对象
     * @param markdownContent Markdown内容
     */
    private static void parseAndCreateDocumentStructure(XWPFDocument document, String markdownContent) {
        // 用于匹配ECharts代码块的正则表达式
        Pattern echartsPattern = Pattern.compile("‍```echarts\\s*\\n(.*?)\\n‍```", Pattern.DOTALL);
        // 用于匹配表格的正则表达式
        Pattern tablePattern = Pattern.compile("(\\|[^\\n]*\\|\\s*\\n\\s*\\|[-|\\s]*\\|\\s*\\n(?:\\s*\\|[^\\n]*\\|\\s*\\n?)*)", Pattern.MULTILINE);
        // 用于匹配标题的正则表达式
        Pattern headerPattern = Pattern.compile("^(#{1,6})\\s+(.*)$", Pattern.MULTILINE);
        
        String[] lines = markdownContent.split("\n");
        int chartIndex = 1;
        int tableIndex = 1;
        
        for (int i = 0; i < lines.length; i++) {
            String line = lines[i];
            
            // 检查是否为标题
            Matcher headerMatcher = headerPattern.matcher(line);
            if (headerMatcher.find()) {
                int level = headerMatcher.group(1).length();
                String title = headerMatcher.group(2);
                
                XWPFParagraph headerParagraph = document.createParagraph();
                setHeaderStyle(headerParagraph, level);
                setHeaderParagraphStyle(headerParagraph, level);
                XWPFRun headerRun = headerParagraph.createRun();
                headerRun.setText(title);
                headerRun.setBold(true);
                headerRun.setFontFamily("宋体");
                
                // 根据标题级别设置字体大小
                int fontSize = 16; // 默认H3
                switch (level) {
                    case 1: fontSize = 22; break; // H1
                    case 2: fontSize = 20; break; // H2
                    case 3: fontSize = 18; break; // H3
                    case 4: fontSize = 16; break; // H4
                    case 5: fontSize = 14; break; // H5
                    case 6: fontSize = 12; break; // H6
                }
                headerRun.setFontSize(fontSize);
                
                continue;
            }
            
            // 检查是否为ECharts图表
            if (line.trim().equals("‍```echarts")) {
                // 查找图表代码块的结束位置
                StringBuilder chartCode = new StringBuilder();
                i++; // 移动到下一行
                while (i < lines.length && !lines[i].trim().equals("‍```")) {
                    chartCode.append(lines[i]).append("\n");
                    i++;
                }
                
                // 创建图表占位符
                XWPFParagraph chartTitleParagraph = document.createParagraph();
                setDefaultParagraphStyle(chartTitleParagraph); // 图表标题使用默认段落样式
                XWPFRun chartTitleRun = chartTitleParagraph.createRun();
                chartTitleRun.setText("图表 " + chartIndex + ":");
                chartTitleRun.setBold(true);
                chartTitleRun.setFontFamily("宋体");
    
                // 创建实际的图表对象
                try {
                    createChartInDocument(document, "chart" + chartIndex, chartCode.toString());
                } catch (Exception e) {
                    // 如果创建图表失败,至少添加占位符
                    XWPFParagraph chartParagraph = document.createParagraph();
                    chartParagraph.setAlignment(ParagraphAlignment.CENTER);
                    setDefaultParagraphStyle(chartParagraph);
                    XWPFRun chartRun = chartParagraph.createRun();
                    chartRun.setText("${chart" + chartIndex + "}");
                }
                
                chartIndex++;
                continue;
            }
            
            // 检查是否为表格开始
            if (line.startsWith("|")) {
                // 收集表格的所有行
                StringBuilder tableMarkdown = new StringBuilder(line).append("\n");
                i++; // 移动到下一行
                while (i < lines.length && (lines[i].startsWith("|") || lines[i].trim().matches("^\\|?\\s*[-|:\\s]+\\|?\\s*$"))) {
                    tableMarkdown.append(lines[i]).append("\n");
                    i++;
                }
                i--; // 回退一行,因为循环会自动增加i
                
                // 创建表格占位符
                XWPFParagraph tableTitleParagraph = document.createParagraph();
                setDefaultParagraphStyle(tableTitleParagraph); // 表格标题使用默认段落样式
                XWPFRun tableTitleRun = tableTitleParagraph.createRun();
                tableTitleRun.setText("表格 " + tableIndex + ":");
                tableTitleRun.setBold(true);
                tableTitleRun.setFontFamily("宋体");
    
                XWPFParagraph tableParagraph = document.createParagraph();
                setDefaultParagraphStyle(tableParagraph);
                XWPFRun tableRun = tableParagraph.createRun();
                tableRun.setText("${table" + tableIndex + "}");
                
                tableIndex++;
                continue;
            }
            
            // 普通段落
            if (!line.trim().isEmpty()) {
                XWPFParagraph paragraph = document.createParagraph();
                setDefaultParagraphStyle(paragraph); // 内容段落使用默认样式
                XWPFRun run = paragraph.createRun();
                run.setText(line);
                run.setFontFamily("宋体");
                run.setFontSize(12); // 小四号字体
            }
        }
    }
  2. ECharts 图表转换

    EChartsToWordConverter 类负责将 ECharts 配置转换为 Word 图表数据:

    java 复制代码
    public static void convertEChartsToWordChart(WordParams params, String chartKey, String echartsConfig) throws IOException {
        try {
            // 预处理ECharts配置,将其转换为有效的JSON格式
            String jsonConfig = convertEChartsToJson(echartsConfig);
    
            JsonNode rootNode = objectMapper.readTree(jsonConfig);
    
            // 获取图表标题
            String title = rootNode.path("title").path("text").asText("默认标题");
    
            // 创建图表
            ChartTable chartTable = params.addChart(chartKey).setTitle(title);
    
            // 处理 X 轴数据
            JsonNode xAxisNode = rootNode.path("xAxis");
            if (xAxisNode.isArray()) {
                xAxisNode = xAxisNode.get(0); // 多个 x 轴时取第一个
            }
    
            if (!xAxisNode.isMissingNode()) {
                JsonNode xAxisData = xAxisNode.path("data");
                if (!xAxisData.isMissingNode()) {
                    List<String> xAxisLabels = new ArrayList<>();
                    for (JsonNode dataNode : xAxisData) {
                        xAxisLabels.add(dataNode.asText());
                    }
                    chartTable.getXAxis().addAllData(xAxisLabels);
                }
            }
    
            // 处理 Y 轴数据和系列数据
            JsonNode seriesNode = rootNode.path("series");
            if (seriesNode.isArray()) {
                for (JsonNode serie : seriesNode) {
                    String seriesName = serie.path("name").asText("数据系列");
                    JsonNode seriesData = serie.path("data");
    
                    if (!seriesData.isMissingNode() && seriesData.isArray()) {
                        List<Number> dataValues = new ArrayList<>();
                        for (JsonNode dataNode : seriesData) {
                            if (dataNode.isNumber()) {
                                dataValues.add(dataNode.numberValue());
                            } else {
                                dataValues.add(0);
                            }
                        }
                        chartTable.newYAxis(seriesName).addAllData(dataValues);
                    }
                }
            }
    
            // 如果有 Y 轴名称设置,更新第一个 Y 轴的标题
            JsonNode yAxisNode = rootNode.path("yAxis");
            if (yAxisNode.isArray()) {
                yAxisNode = yAxisNode.get(0); // 多个 y 轴时取第一个
            }
    
            if (!yAxisNode.isMissingNode()) {
                String yAxisName = yAxisNode.path("name").asText("");
                if (!yAxisName.isEmpty() && !chartTable.getYAxis().isEmpty()) {
                    // 获取第一个 Y 轴并设置标题
                    String firstKey = chartTable.getYAxis().keySet().iterator().next();
                    chartTable.getYAxis(firstKey).setTitle(yAxisName);
                }
            }
        } catch (Exception e) {
            // 如果解析失败,创建一个默认的空图表
            ChartTable chartTable = params.addChart(chartKey).setTitle("默认图表标题");
            chartTable.getXAxis().addAllData("数据1", "数据2", "数据3");
            chartTable.newYAxis("默认系列").addAllData(10, 20, 30);
            throw new IOException("解析ECharts配置时出错: " + e.getMessage(), e);
        }
    }
  3. 表格解析

    MarkdownTableParser 类负责解析 Markdown 表格:

    java 复制代码
    public static List<List<String>> parseTable(String markdownTable) {
        List<List<String>> tableData = new ArrayList<>();
    
        String[] lines = markdownTable.split("\n");
        for (String line : lines) {
            line = line.trim();
            // 跳过分隔行(只包含|和-的行)
            if (line.matches("^\\|?\\s*[-|:\\s]+\\|?\\s*$") && line.contains("-")) {
                continue;
            }
    
            if (line.startsWith("|")) {
                line = line.substring(1);
            }
            if (line.endsWith("|")) {
                line = line.substring(0, line.length() - 1);
            }
    
            String[] cells = line.split("\\|");
            List<String> row = new ArrayList<>();
            for (String cell : cells) {
                row.add(cell.trim());
            }
            // 只有当行不为空时才添加到表格数据中
            if (!row.isEmpty() && !(row.size() == 1 && row.get(0).isEmpty())) {
                tableData.add(row);
            }
        }
    
        return tableData;
    }

🥰使用示例

使用 md2doc-plus 非常简单,只需要几行代码:

java 复制代码
public class Test {
    public static void main(String[] args) throws Exception {
        MarkdownToWordConverter.convertMarkdownFileToWord("./markdown/未命名.md",
                "./word/未命名_output.docx");
    }
}

😜效果验证

  • 原始markdown文件:
  • 转换后的Word文档

🤨存在的问题

2025-08-15 已解决

  1. word章节标题样式缺失,无法自动生成目录;
  2. 图表样式缺失,图表显示不全,需手动调整;
  3. 标题序号缺失、标题格式缺失,不能自动列出目录;
相关推荐
野犬寒鸦17 分钟前
今日面试之项目拷打:锁与事务的深度解析
java·服务器·数据库·后端
ajassi200033 分钟前
开源 java android app 开发(十五)自定义绘图控件--仪表盘
android·java·开源
FrankYoou36 分钟前
Spring Boot 自动配置之 TaskExecutor
java·spring boot
爱读源码的大都督37 分钟前
Spring AI Alibaba JManus底层实现剖析
java·人工智能·后端
间彧1 小时前
ReentrantLock与ReadWriteLock在性能和使用场景上有什么区别?
java
Lbwnb丶1 小时前
p6spy 打印完整sql
java·数据库·sql
间彧1 小时前
公平锁与非公平锁的选择策略与场景分析
java
渣哥1 小时前
锁升级到底能不能“退烧”?synchronized 释放后状态解析
java
间彧1 小时前
Java ReentrantLock详解与应用实战
java
间彧1 小时前
volatile与Atomic类的性能对比与适用场景分析
java