markdown转为pdf导出
将markdown 导出为pdf
markdown 导出为pdf,得先将markdown->html->pdf
1.maven依赖
<!-- Markdown转pdf相关 --><dependency><groupId>com.vladsch.flexmark</groupId><artifactId>flexmark-all</artifactId><version>0.64.0</version></dependency><dependency><groupId>com.atlassian.commonmark</groupId><artifactId>commonmark</artifactId><version>0.15.2</version></dependency><dependency><groupId>org.xhtmlrenderer</groupId><artifactId>flying-saucer-pdf</artifactId><version>9.5.1</version></dependency><dependency><groupId>com.openhtmltopdf</groupId><artifactId>openhtmltopdf-core</artifactId><version>1.0.10</version></dependency><dependency><groupId>com.openhtmltopdf</groupId><artifactId>openhtmltopdf-pdfbox</artifactId><version>1.0.10</version></dependency><dependency><groupId>org.jsoup</groupId><artifactId>jsoup</artifactId><version>1.15.3</version></dependency><!-- 如果需要其他字体支持 --><dependency><groupId>com.openhtmltopdf</groupId><artifactId>openhtmltopdf-rtl-support</artifactId><version>1.0.10</version></dependency>
2.工具类
import com.gcbd.framework.common.exception.ServiceException;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import lombok.extern.slf4j.Slf4j;import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;/*** @ClassName MarkdownToPdfExporterUtil* @Description MarkdownToPdfExporterUtil* @Author zxr* @Date 2025/10/29*/
@Slf4j
public class MarkdownToPdfExporterUtil {private static final Parser parser = Parser.builder().build();private static final HtmlRenderer renderer = HtmlRenderer.builder().build();public static String convertMarkdownToHtml(String markdownTitle, String markdownContent) {try {// 预处理:将 Markdown 表格转换为 HTML 表格String processedContent = preprocessTables(markdownContent);Node document = parser.parse(processedContent);String htmlContent = renderer.render(document);// 包装成完整的 HTML 文档return """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><style>%s</style></head><body><div class="container"><div class="main-content">%s</div></div></body></html>""".formatted(getDocumentStyles(),htmlContent);} catch (Exception e) {throw new RuntimeException("HTML 转换失败", e);}}/*** 预处理:将 Markdown 表格语法转换为 HTML 表格*/private static String preprocessTables(String markdownContent) {if (markdownContent == null || markdownContent.trim().isEmpty()) {return markdownContent;}String[] lines = markdownContent.split("\n");StringBuilder result = new StringBuilder();List<String> tableLines = new ArrayList<>();boolean inTable = false;for (int i = 0; i < lines.length; i++) {String line = lines[i];// 检测表格开始(包含 | 的行,且不是代码块内的)if (isTableLine(line) && !isInCodeBlock(result.toString())) {if (!inTable) {// 开始表格inTable = true;tableLines.clear();}tableLines.add(line);} else {if (inTable && tableLines.size() >= 2) {// 结束表格,转换并添加到结果result.append(convertTableToHtml(tableLines)).append("\n");inTable = false;tableLines.clear();}result.append(line).append("\n");}}// 处理文件末尾的表格if (inTable && tableLines.size() >= 2) {result.append(convertTableToHtml(tableLines)).append("\n");}return result.toString();}/*** 判断是否为表格行*/private static boolean isTableLine(String line) {if (line == null || line.trim().isEmpty()) {return false;}// 简单的表格检测:包含 | 字符且不是标题分隔线return line.contains("|") &&!line.trim().matches("^#+.*") && // 不是标题!line.matches("^=+$|^-+$"); // 不是标题下划线}/*** 判断是否在代码块内*/private static boolean isInCodeBlock(String text) {// 简单的代码块检测:统计 ```的数量int backtickCount = 0;for (char c : text.toCharArray()) {if (c == '`') {backtickCount++;}}return backtickCount % 2 != 0;}/*** 将 Markdown 表格转换为 HTML 表格*/private static String convertTableToHtml(List<String> tableLines) {if (tableLines.size() < 2) {return String.join("\n", tableLines);}StringBuilder htmlTable = new StringBuilder();htmlTable.append("<div class=\"table-container\">\n");htmlTable.append("<table>\n");// 处理表头String headerLine = tableLines.get(0);htmlTable.append("<thead>\n<tr>\n");String[] headers = parseTableRow(headerLine);for (String header : headers) {htmlTable.append("<th>").append(escapeHtml(header.trim())).append("</th>\n");}htmlTable.append("</tr>\n</thead>\n");// 处理分隔线(第二行)String separatorLine = tableLines.get(1);int[] alignments = parseAlignment(separatorLine);// 处理数据行htmlTable.append("<tbody>\n");for (int i = 2; i < tableLines.size(); i++) {String[] cells = parseTableRow(tableLines.get(i));htmlTable.append("<tr>\n");for (int j = 0; j < cells.length; j++) {String alignment = getAlignmentClass(alignments, j);htmlTable.append("<td").append(alignment).append(">").append(escapeHtml(cells[j].trim())).append("</td>\n");}htmlTable.append("</tr>\n");}htmlTable.append("</tbody>\n");htmlTable.append("</table>\n");htmlTable.append("</div>");return htmlTable.toString();}/*** 解析表格行*/private static String[] parseTableRow(String line) {// 移除行首尾的 |,然后按 | 分割String cleaned = line.trim();if (cleaned.startsWith("|")) {cleaned = cleaned.substring(1);}if (cleaned.endsWith("|")) {cleaned = cleaned.substring(0, cleaned.length() - 1);}return cleaned.split("\\|", -1); // -1 保留空字符串}/*** 解析对齐方式*/private static int[] parseAlignment(String separatorLine) {String[] cells = parseTableRow(separatorLine);int[] alignments = new int[cells.length];for (int i = 0; i < cells.length; i++) {String cell = cells[i].trim();if (cell.startsWith(":") && cell.endsWith(":")) {alignments[i] = 1; // 居中对齐} else if (cell.endsWith(":")) {alignments[i] = 2; // 右对齐} else if (cell.startsWith(":")) {alignments[i] = 3; // 左对齐(默认)} else {alignments[i] = 0; // 默认左对齐}}return alignments;}/*** 获取对齐方式的 CSS 类*/private static String getAlignmentClass(int[] alignments, int index) {if (index >= alignments.length) {return "";}switch (alignments[index]) {case 1: return " align=\"center\"";case 2: return " align=\"right\"";case 3: return " align=\"left\"";default: return "";}}/*** HTML 转义*/private static String escapeHtml(String text) {return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'");}/*** 获取文档样式 - 优化中文字体支持*/private static String getDocumentStyles() {String fontFace = loadFontFace();return fontFace + """* {margin: 0;padding: 0;box-sizing: border-box;}body {/* 强制使用 SimHei 字体,确保 PDF 渲染一致性 */font-family: 'SimHei', 'Microsoft YaHei', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans GB','WenQuanYi Micro Hei', 'Source Han Sans CN', 'Noto Sans CJK SC',sans-serif !important;line-height: 1.8;color: #2c3e50;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);min-height: 100vh;padding: 20px;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;text-rendering: optimizeLegibility;}/* 特别为代码块和预格式文本设置中文字体 */pre, code {font-family: 'SimHei', 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', 'Monaco', 'Courier New', monospace !important;}/* JSON 内容通常出现在 pre 或 code 标签中 */pre {background: #1a1a1a;color: #f8f8f2;padding: 1.5rem;border-radius: 8px;overflow-x: auto;margin: 1.8rem 0;border-left: 4px solid #007bff;font-size: 0.95rem;line-height: 1.5;tab-size: 4;white-space: pre-wrap; /* 确保长文本换行 */word-wrap: break-word;}code {background: #f1f3f4;padding: 0.2rem 0.4rem;border-radius: 4px;font-size: 0.9em;color: #e74c3c;}pre code {background: none;padding: 0;color: inherit;font-size: inherit;}/* 确保所有文本元素都使用中文字体 */.container, .main-content, .document-header, .document-title, .document-meta,p, h1, h2, h3, h4, h5, h6,li, td, th, span, div {font-family: 'SimHei', 'Microsoft YaHei', 'PingFang SC', sans-serif !important;}/* 其余样式保持不变... */.container {max-width: 1200px;margin: 0 auto;background: white;border-radius: 15px;box-shadow: 0 20px 40px rgba(0,0,0,0.1);overflow: hidden;}.document-header {background: linear-gradient(135deg, #007bff, #0056b3);color: white;padding: 40px;text-align: center;}.document-title {font-size: 2.8rem;font-weight: 700;margin-bottom: 1rem;text-shadow: 0 2px 4px rgba(0,0,0,0.3);line-height: 1.3;word-wrap: break-word;word-break: break-word;}.document-meta {display: flex;justify-content: center;gap: 2rem;font-size: 1rem;opacity: 0.9;font-family: inherit;}.main-content {padding: 40px;}/* 标题样式 - 优化中文排版 */h1, h2, h3, h4, h5, h6 {margin: 2.5rem 0 1.5rem;font-weight: 600;line-height: 1.4;color: #2c3e50;font-family: inherit;text-align: left;}h1 {font-size: 2.2rem;border-bottom: 3px solid #007bff;padding-bottom: 0.8rem;margin-top: 3rem;letter-spacing: -0.5px;}h2 {font-size: 1.8rem;border-left: 5px solid #007bff;padding-left: 1.2rem;background: #f8f9fa;padding: 1.2rem;border-radius: 0 8px 8px 0;margin-left: -1.2rem;}h3 {font-size: 1.5rem;color: #495057;padding-bottom: 0.3rem;border-bottom: 1px solid #e9ecef;}h4 { font-size: 1.3rem; }h5 { font-size: 1.1rem; }h6 { font-size: 1rem; color: #6c757d; }/* 表格样式 */table {width: 100%;border-collapse: collapse;margin: 2.5rem 0;box-shadow: 0 1px 3px rgba(0,0,0,0.1);border-radius: 8px;overflow: hidden;font-family: inherit;}th, td {padding: 1.2rem 1rem;text-align: left;border: 1px solid #dee2e6;line-height: 1.6;font-size: 1rem;}th {background: #007bff;color: white;font-weight: 600;font-size: 1rem;font-family: inherit;}td {background: white;vertical-align: top;}tr:nth-child(even) td {background: #f8f9fa;}tr:hover td {background: #e3f2fd;}/* 代码块样式 */pre {background: #1a1a1a;color: #f8f8f2;padding: 1.5rem;border-radius: 8px;overflow-x: auto;margin: 1.8rem 0;border-left: 4px solid #007bff;font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono','Menlo', 'Monaco', 'Courier New', 'monospace';font-size: 0.95rem;line-height: 1.5;tab-size: 4;}code {background: #f1f3f4;padding: 0.2rem 0.4rem;border-radius: 4px;font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono','Menlo', 'Monaco', 'Courier New', 'monospace';font-size: 0.9em;color: #e74c3c;}pre code {background: none;padding: 0;color: inherit;font-size: inherit;}/* 图片样式 */img {max-width: 100%;height: auto;border: 1px solid #dee2e6;border-radius: 8px;box-shadow: 0 4px 6px rgba(0,0,0,0.1);margin: 1.5rem 0;display: block;}/* 段落和文本 - 优化中文阅读体验 */p {margin-bottom: 1.5rem;text-align: justify;font-size: 1.1rem;line-height: 1.8;word-spacing: 0.05em;font-family: inherit;}strong {font-weight: 650;color: #e74c3c;}em {font-style: italic;color: #6c757d;}/* 链接样式 */a {color: #007bff;text-decoration: none;transition: color 0.2s ease;}a:hover {color: #0056b3;text-decoration: underline;}/* 列表样式 - 优化中文列表 */ul, ol {margin: 1.8rem 0;padding-left: 2.5rem;font-size: 1.1rem;}li {margin-bottom: 0.8rem;line-height: 1.8;text-align: left;}ul li {list-style: none;position: relative;}ul li::before {content: '•';color: #007bff;font-weight: bold;position: absolute;left: -1.5rem;font-size: 1.2rem;}ol {counter-reset: list-counter;}ol li {list-style: none;position: relative;counter-increment: list-counter;}ol li::before {content: counter(list-counter) '.';color: #007bff;font-weight: 600;position: absolute;left: -2rem;min-width: 1.5rem;}/* 引用块样式 */blockquote {background: #f8f9fa;border-left: 4px solid #007bff;margin: 2rem 0;padding: 1.5rem;border-radius: 0 8px 8px 0;font-style: italic;color: #495057;}blockquote p {margin-bottom: 0.5rem;font-size: 1.05rem;}/* 水平线 */hr {border: none;height: 2px;background: linear-gradient(90deg, transparent, #007bff, transparent);margin: 3rem 0;}/* 页脚 */.document-footer {background: #343a40;color: white;text-align: center;padding: 2rem;margin-top: 3rem;font-family: inherit;}.document-footer p {margin: 0;font-size: 0.9rem;opacity: 0.8;text-align: center;}/* 特殊标记 */.api-section {background: #e8f5e8;border: 1px solid #28a745;border-radius: 8px;padding: 1.8rem;margin: 2.5rem 0;}.parameter-table {font-size: 0.95rem;}.parameter-table th {background: #495057;white-space: nowrap;}.example-section {background: #fff3cd;border: 1px solid #ffc107;border-radius: 8px;padding: 1.8rem;margin: 2.5rem 0;}.test-case {background: #d1ecf1;border: 1px solid #17a2b8;border-radius: 8px;padding: 1.8rem;margin: 2.5rem 0;}/* 响应式设计 */@media (max-width: 768px) {.container {margin: 10px;border-radius: 10px;}.document-header {padding: 2rem 1rem;}.document-title {font-size: 2rem;line-height: 1.2;}.main-content {padding: 1.5rem;}table {display: block;overflow-x: auto;font-size: 0.9rem;}.document-meta {flex-direction: column;gap: 0.5rem;font-size: 0.9rem;}h1 { font-size: 1.8rem; }h2 { font-size: 1.5rem; margin-left: 0; padding-left: 1rem; }h3 { font-size: 1.3rem; }p, li {font-size: 1rem;text-align: left;}pre {padding: 1rem;font-size: 0.85rem;}}@media (max-width: 480px) {body {padding: 10px;}.document-title {font-size: 1.6rem;}.main-content {padding: 1rem;}th, td {padding: 0.8rem 0.5rem;font-size: 0.85rem;}}/* 打印样式 */@media print {body {background: white !important;padding: 0;}.container {box-shadow: none;border-radius: 0;}.document-header {background: #007bff !important;-webkit-print-color-adjust: exact;print-color-adjust: exact;}a {color: #0056b3;text-decoration: underline;}.document-meta {color: white !important;}}""";}/*** 将 HTML 转换为 PDF 字节数组 - 确保中文字体支持*/public static byte[] convertHtmlToPdf(String htmlContent) {try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {PdfRendererBuilder builder = new PdfRendererBuilder();// 强制注册中文字体registerChineseFonts(builder);builder.withHtmlContent(htmlContent, null);builder.toStream(baos);builder.run();return baos.toByteArray();} catch (Exception e) {throw new RuntimeException("PDF 生成失败: " + e.getMessage(), e);}}/*** 强制注册中文字体*/private static void registerChineseFonts(PdfRendererBuilder builder) {// 定义可能的字体路径String[] fontPaths = {"font/SimHei.ttf", // 项目根目录"src/main/resources/font/SimHei.ttf", // Maven 资源目录"resources/font/SimHei.ttf", // 资源目录"./font/SimHei.ttf", // 当前目录"SimHei.ttf" // 直接文件名};boolean fontRegistered = false;for (String fontPath : fontPaths) {try {// 先尝试类路径InputStream fontStream = MarkdownToPdfExporterUtil.class.getClassLoader().getResourceAsStream(fontPath);if (fontStream != null) {// 重要:使用 Supplier 确保每次都能获得新的流builder.useFont(() -> {InputStream stream = MarkdownToPdfExporterUtil.class.getClassLoader().getResourceAsStream(fontPath);if (stream == null) {throw new RuntimeException("无法加载字体: " + fontPath);}return stream;}, "SimHei");log.info("成功注册字体: {} (类路径)", fontPath);fontRegistered = true;break;}// 再尝试文件系统File fontFile = new File(fontPath);if (fontFile.exists()) {builder.useFont(fontFile, "SimHei");log.info("成功注册字体: {} (文件系统)", fontPath);fontRegistered = true;break;}} catch (Exception e) {throw new ServiceException(500, "字体注册失败 " + fontPath + ": " + e.getMessage());}}if (!fontRegistered) {log.error("警告: 未找到任何中文字体文件,中文将显示为 ###,请将 SimHei.ttf 字体文件放置在以下位置之一:classpath:font/SimHei.ttf,\n" +" // 项目根目录:font/SimHei.ttf,src/main/resources/font/SimHei.ttf:");}}/*** 加载字体定义 - 增强版本*/private static String loadFontFace() {String simHeiBase64 = loadFontAsBase64("font/SimHei.ttf");if (!simHeiBase64.isEmpty()) {return """@font-face {font-family: 'SimHei';src: url('data:font/truetype;charset=utf-8;base64,%s') format('truetype');font-weight: normal;font-style: normal;font-display: swap;}@font-face {font-family: 'SimHei';src: url('data:font/truetype;charset=utf-8;base64,%s') format('truetype');font-weight: bold;font-style: normal;font-display: swap;}@font-face {font-family: 'SimHei';src: url('data:font/truetype;charset=utf-8;base64,%s') format('truetype');font-weight: normal;font-style: italic;font-display: swap;}""".formatted(simHeiBase64, simHeiBase64, simHeiBase64);}// 如果字体加载失败,提供回退方案return """/* 字体加载失败,使用系统字体回退 */""";}private static String loadFontAsBase64(String fontPath) {InputStream fontStream = null;try {// 1. 尝试从类路径加载fontStream = MarkdownToPdfExporterUtil.class.getClassLoader().getResourceAsStream(fontPath);// 2. 尝试绝对路径if (fontStream == null) {File fontFile = new File(fontPath);if (fontFile.exists()) {fontStream = new FileInputStream(fontFile);}}// 3. 尝试常见位置if (fontStream == null) {String[] possiblePaths = {"src/main/resources/" + fontPath,"resources/" + fontPath,"font/" + fontPath,"../" + fontPath};for (String path : possiblePaths) {File fontFile = new File(path);if (fontFile.exists()) {fontStream = new FileInputStream(fontFile);break;}}}//字体文件未找到,请将 SimHei.ttf 放置在以下位置之一:classpath:font/SimHei.ttf,// 项目根目录:font/SimHei.ttf,src/main/resources/font/SimHei.ttf:if (fontStream == null) return "";byte[] fontBytes = fontStream.readAllBytes();String base64 = Base64.getEncoder().encodeToString(fontBytes);log.info("字体加载成功,Base64 长度: {}", base64.length());return base64;} catch (Exception e) {log.error("加载字体失败:{}", fontPath);log.error("错误信息:{}", e.getMessage());return "";} finally {if (fontStream != null) {try {fontStream.close();} catch (Exception e) {}}}}}
3.controller
@GetMapping("/downMarkdownConvertPdf")@Operation(summary = "markdown转pdf下载(别带自闭和标签 比如:<br>)")@Parameter(name = "docId", description = "编号", required = true, example = "1024")@PermitAll@TenantIgnorepublic void exportMarkdownToPdf(@RequestParam("docId") String docId, HttpServletResponse response) throws IOException {try {CatalogueDoc catalogueDoc = catalogueDocService.getById(docId);if (catalogueDoc == null || StringUtils.isBlank(catalogueDoc.getContent()))return;String markdownContent = catalogueDoc.getContent();String filename = catalogueDoc.getTitle();// 生成 HTML和PDFString html = MarkdownToPdfExporterUtil.convertMarkdownToHtml(filename, markdownContent);byte[] pdfBytes = MarkdownToPdfExporterUtil.convertHtmlToPdf(html);// 设置响应头response.setContentType(MediaType.APPLICATION_PDF_VALUE);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentLength(pdfBytes.length);response.setHeader(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\"" + URLEncoder.encode(filename, StandardCharsets.UTF_8) + ".pdf\"");// 添加缓存控制头response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");response.setHeader(HttpHeaders.PRAGMA, "no-cache");response.setDateHeader(HttpHeaders.EXPIRES, 0);// 直接写入二进制数据try (OutputStream outputStream = response.getOutputStream()) {outputStream.write(pdfBytes);outputStream.flush();}} catch (Exception e) {// 错误处理response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);response.setContentType(MediaType.TEXT_PLAIN_VALUE);response.setCharacterEncoding(StandardCharsets.UTF_8.name());try (OutputStream outputStream = response.getOutputStream()) {outputStream.write(("PDF 生成失败: " + e.getMessage()).getBytes(StandardCharsets.UTF_8));}}}
4.字体截图

