- Markdown 编辑器:实时预览、PDF 导出、独立查看器 - 数据库优化:动态连接池、查询缓存、Redis Pipeline - 窗口置顶功能 - 文件系统增强:右键菜单、编辑器集成、收藏夹重构 - 安全修复:XSS 防护、路径穿越、HTML 注入 - 代码质量:正则预编译、缓存锁优化、死代码清理
379 lines
9.7 KiB
Go
379 lines
9.7 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"html"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/chromedp/cdproto/page"
|
||
"github.com/chromedp/chromedp"
|
||
"github.com/yuin/goldmark"
|
||
"u-desk/internal/common"
|
||
)
|
||
|
||
// PdfExportRequest PDF导出请求结构体
|
||
type PdfExportRequest struct {
|
||
Content string `json:"content"` // Markdown/HTML内容
|
||
Title string `json:"title"` // PDF标题
|
||
FileName string `json:"fileName"` // 文件名(不含扩展名)
|
||
FontSize int `json:"fontSize"` // 字体大小
|
||
PageWidth int `json:"pageWidth"` // 页面宽度(mm)
|
||
PageHeight int `json:"pageHeight"` // 页面高度(mm)
|
||
}
|
||
|
||
// PdfExportResponse PDF导出响应结构体
|
||
type PdfExportResponse struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
Path string `json:"path"` // PDF文件保存路径
|
||
Size int64 `json:"size"` // 文件大小(字节)
|
||
}
|
||
|
||
// PdfAPI PDF导出API
|
||
type PdfAPI struct {
|
||
// 可以在这里添加依赖,如文件系统服务等
|
||
}
|
||
|
||
// NewPdfAPI 创建PDF导出API
|
||
func NewPdfAPI() (*PdfAPI, error) {
|
||
return &PdfAPI{}, nil
|
||
}
|
||
|
||
// ExportMarkdownToPDF 将Markdown内容导出为PDF - 使用chromedp实现
|
||
func (api *PdfAPI) ExportMarkdownToPDF(req PdfExportRequest) (*PdfExportResponse, error) {
|
||
// 验证参数
|
||
if strings.TrimSpace(req.Content) == "" {
|
||
return nil, fmt.Errorf("内容不能为空")
|
||
}
|
||
|
||
if strings.TrimSpace(req.FileName) == "" {
|
||
req.FileName = "document_" + time.Now().Format("20060102_150405")
|
||
}
|
||
|
||
if req.FontSize <= 0 {
|
||
req.FontSize = 12
|
||
}
|
||
|
||
// 设置默认页面尺寸(A4)
|
||
if req.PageWidth <= 0 {
|
||
req.PageWidth = 210
|
||
}
|
||
if req.PageHeight <= 0 {
|
||
req.PageHeight = 297
|
||
}
|
||
|
||
// 将Markdown转换为HTML
|
||
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
|
||
|
||
// 使用chromedp生成PDF
|
||
pdfBuffer, err := api.generatePDFFromHTML(htmlContent, req.Title, req.PageWidth, req.PageHeight)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("生成PDF失败: %v", err)
|
||
}
|
||
|
||
// 生成文件名
|
||
if !strings.HasSuffix(strings.ToLower(req.FileName), ".pdf") {
|
||
req.FileName += ".pdf"
|
||
}
|
||
|
||
// 获取用户桌面目录作为默认保存位置
|
||
saveDir := api.getDesktopDirectory()
|
||
|
||
// 确保目录存在
|
||
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
||
return nil, fmt.Errorf("创建目录失败: %v", err)
|
||
}
|
||
|
||
// 完整保存路径
|
||
savePath := filepath.Join(saveDir, filepath.Base(req.FileName))
|
||
|
||
// 保存PDF文件
|
||
err = os.WriteFile(savePath, pdfBuffer, 0644)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("保存PDF文件失败: %v", err)
|
||
}
|
||
|
||
// 获取文件信息
|
||
fileInfo, err := os.Stat(savePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取文件信息失败: %v", err)
|
||
}
|
||
|
||
// 返回成功响应
|
||
return &PdfExportResponse{
|
||
Success: true,
|
||
Message: "PDF生成成功",
|
||
Path: savePath,
|
||
Size: fileInfo.Size(),
|
||
}, nil
|
||
}
|
||
|
||
// markdownToHTML 将Markdown转换为HTML
|
||
func (api *PdfAPI) markdownToHTML(markdownContent string, title string, fontSize int) string {
|
||
// 基础HTML模板
|
||
htmlTemplate := `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 40px 20px;
|
||
font-size: %dpx;
|
||
}
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin-top: 24px;
|
||
margin-bottom: 16px;
|
||
font-weight: 600;
|
||
line-height: 1.25;
|
||
}
|
||
h1 {
|
||
font-size: 2em;
|
||
border-bottom: 1px solid #eaecef;
|
||
padding-bottom: 0.3em;
|
||
}
|
||
h2 {
|
||
font-size: 1.5em;
|
||
border-bottom: 1px solid #eaecef;
|
||
padding-bottom: 0.3em;
|
||
}
|
||
h3 {
|
||
font-size: 1.25em;
|
||
}
|
||
h4 {
|
||
font-size: 1em;
|
||
}
|
||
h5 {
|
||
font-size: 0.875em;
|
||
}
|
||
h6 {
|
||
font-size: 0.85em;
|
||
color: #6a737d;
|
||
}
|
||
p {
|
||
margin-bottom: 16px;
|
||
}
|
||
blockquote {
|
||
margin: 0 0 16px;
|
||
padding: 0 1em;
|
||
color: #6a737d;
|
||
border-left: 0.25em solid #dfe2e5;
|
||
}
|
||
ul, ol {
|
||
padding-left: 2em;
|
||
margin-bottom: 16px;
|
||
}
|
||
li {
|
||
margin-bottom: 4px;
|
||
}
|
||
code {
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
background-color: rgba(27,31,35,0.05);
|
||
border-radius: 3px;
|
||
font-size: 85%;
|
||
margin: 0;
|
||
padding: 0.2em 0.4em;
|
||
}
|
||
pre {
|
||
background-color: #f6f8fa;
|
||
border-radius: 3px;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
margin-bottom: 16px;
|
||
}
|
||
pre code {
|
||
background-color: transparent;
|
||
padding: 0;
|
||
margin: 0;
|
||
font-size: 100%;
|
||
}
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin-bottom: 16px;
|
||
border: 1px solid #dfe2e5;
|
||
}
|
||
th, td {
|
||
padding: 8px 12px;
|
||
border: 1px solid #dfe2e5;
|
||
text-align: left;
|
||
}
|
||
th {
|
||
background-color: #f6f8fa;
|
||
font-weight: 600;
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
margin: 16px 0;
|
||
}
|
||
hr {
|
||
height: 0.25em;
|
||
padding: 0;
|
||
margin: 24px 0;
|
||
background-color: #e1e4e8;
|
||
border: 0;
|
||
}
|
||
.title {
|
||
text-align: center;
|
||
margin-bottom: 32px;
|
||
font-size: 1.5em;
|
||
color: #2c3e50;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="title">%s</div>
|
||
%s
|
||
</body>
|
||
</html>`
|
||
|
||
// 标题处理
|
||
docTitle := ""
|
||
if title != "" {
|
||
docTitle = html.EscapeString(title)
|
||
} else {
|
||
docTitle = "文档"
|
||
}
|
||
|
||
// Markdown转HTML(使用goldmark)
|
||
var htmlContent string
|
||
var htmlBuf strings.Builder
|
||
if err := goldmark.Convert([]byte(markdownContent), &htmlBuf); err != nil {
|
||
htmlContent = "<p>Markdown 解析失败</p>"
|
||
} else {
|
||
htmlContent = htmlBuf.String()
|
||
}
|
||
|
||
// 生成完整的HTML
|
||
fullHTML := fmt.Sprintf(htmlTemplate, fontSize, docTitle, htmlContent)
|
||
|
||
return fullHTML
|
||
}
|
||
|
||
// generatePDFFromHTML 使用chromedp从HTML生成PDF
|
||
func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pageHeight int) ([]byte, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
// 配置chromedp选项
|
||
opts := []chromedp.ExecAllocatorOption{
|
||
chromedp.Flag("headless", true),
|
||
chromedp.Flag("disable-gpu", true),
|
||
chromedp.Flag("no-sandbox", true),
|
||
chromedp.Flag("disable-dev-shm-usage", true),
|
||
chromedp.Flag("disable-software-rasterizer", true),
|
||
chromedp.Flag("disable-extensions", true),
|
||
chromedp.Flag("disable-notifications", true),
|
||
}
|
||
|
||
// 在Windows上设置Chrome路径
|
||
if common.IsWindows() {
|
||
// 常见的Windows Chrome路径
|
||
chromePaths := []string{
|
||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
|
||
}
|
||
|
||
for _, path := range chromePaths {
|
||
if _, err := os.Stat(path); err == nil {
|
||
opts = append(opts, chromedp.ExecPath(path))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建执行分配器上下文
|
||
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
|
||
defer allocCancel()
|
||
|
||
// 创建chromedp上下文
|
||
chromeCtx, chromeCancel := chromedp.NewContext(allocCtx)
|
||
defer chromeCancel()
|
||
|
||
// 创建一个临时的目录用于PDF生成
|
||
tempDir, err := os.MkdirTemp("", "pdf_gen")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建临时目录失败: %v", err)
|
||
}
|
||
defer os.RemoveAll(tempDir)
|
||
|
||
// 将HTML写入临时文件
|
||
htmlFile := filepath.Join(tempDir, "document.html")
|
||
if err := os.WriteFile(htmlFile, []byte(htmlContent), 0644); err != nil {
|
||
return nil, fmt.Errorf("写入HTML文件失败: %v", err)
|
||
}
|
||
|
||
var buf []byte
|
||
|
||
// 使用 file URL 加载本地HTML文件
|
||
err = chromedp.Run(chromeCtx,
|
||
// 导航到HTML文件
|
||
chromedp.Navigate("file://"+htmlFile),
|
||
// 等待页面加载完成
|
||
chromedp.WaitReady("body"),
|
||
// 打印到PDF
|
||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
// 设置页面打印参数
|
||
printToPDF := page.PrintToPDF().
|
||
WithPrintBackground(true).
|
||
WithLandscape(false).
|
||
WithMarginTop(0).
|
||
WithMarginBottom(0).
|
||
WithMarginLeft(0).
|
||
WithMarginRight(0).
|
||
WithPaperWidth(float64(pageWidth) / 25.4). // mm to inches
|
||
WithPaperHeight(float64(pageHeight) / 25.4) // mm to inches
|
||
|
||
// 执行打印并获取PDF数据
|
||
var err error
|
||
buf, _, err = printToPDF.Do(ctx)
|
||
return err
|
||
}),
|
||
)
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chromedp执行失败: %v", err)
|
||
}
|
||
|
||
return buf, nil
|
||
}
|
||
|
||
// getDesktopDirectory 获取用户桌面目录
|
||
func (api *PdfAPI) getDesktopDirectory() string {
|
||
// Windows系统
|
||
if common.IsWindows() {
|
||
home := os.Getenv("USERPROFILE")
|
||
if home != "" {
|
||
return filepath.Join(home, "Desktop")
|
||
}
|
||
}
|
||
|
||
// Linux/Mac系统
|
||
home := os.Getenv("HOME")
|
||
if home != "" {
|
||
return filepath.Join(home, "Desktop")
|
||
}
|
||
|
||
// 备用:当前目录
|
||
return "."
|
||
}
|
||
|
||
// SelectDirectory 选择保存目录(简化版,实际应该使用Wails runtime)
|
||
func (api *PdfAPI) SelectDirectory() (string, error) {
|
||
// 简化版:直接返回桌面目录
|
||
desktop := api.getDesktopDirectory()
|
||
if desktop == "." {
|
||
return "", fmt.Errorf("无法确定默认目录")
|
||
}
|
||
return desktop, nil
|
||
} |