将markdown 导出为pdf
markdown 导出为pdf,得先将markdown->html->pdf
1.maven依赖
bash
<!-- 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.工具类
bash
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
bash
@GetMapping("/downMarkdownConvertPdf")
@Operation(summary = "markdown转pdf下载(别带自闭和标签 比如:<br>)")
@Parameter(name = "docId", description = "编号", required = true, example = "1024")
@PermitAll
@TenantIgnore
public 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和PDF
String 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.字体截图
