Files
u-desk/internal/api/pdf_api.go
T
lxy 4675ae381d 重构: wails beta.30与chromedp v0.21升级迁移收口
依赖升级后的代码迁移补完: chromedp新API三处调用点改cdp.Call传参/绑定按beta CLI重生成(101 Methods含ReadClipboardImage)/npm与go依赖对齐
2026-10-24 13:48:18 +08:00

636 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"fmt"
"html"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/yuin/goldmark"
"gorm.io/gorm"
"u-desk/internal/common"
"u-desk/internal/storage"
"u-desk/internal/storage/models"
)
// 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.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.resolveExportDir()
// 确保目录存在
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 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),
}
// 浏览器路径统一走 findChromeExecutable,Chrome 与 Edge 任一可用即可
browserPath, err := findChromeExecutable()
if err != nil {
return nil, err
}
opts = append(opts, chromedp.ExecPath(browserPath))
// 创建执行分配器上下文
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 需整体转义,与 generatePDFFromLocalFile 同姿势,中文/空格临时路径才能正确导航
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(htmlFile)}).String()
err = chromedp.Do(chromeCtx,
chromedp.Navigate(fileURL),
// 等待页面加载完成
chromedp.WaitReady("body"),
// 打印到PDF
chromedp.Func(func(ctx context.Context, t *chromedp.Target) error {
// 新版 cdproto:Command 为变量,参数经结构体字面量由 cdp.Call 传入
yes, zero := true, 0.0
res, err := cdp.Call(ctx, t, page.PrintToPDF, page.PrintToPDFParams{
PrintBackground: &yes,
MarginTop: &zero,
MarginBottom: &zero,
MarginLeft: &zero,
MarginRight: &zero,
PaperWidth: float64(pageWidth) / 25.4, // mm to inches
PaperHeight: float64(pageHeight) / 25.4, // mm to inches
})
if err != nil {
return err
}
buf = res.Data
return nil
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp执行失败: %v", err)
}
if len(buf) == 0 {
return nil, fmt.Errorf("PDF生成结果为空")
}
return buf, nil
}
// findChromeExecutable 探测本机可用的浏览器可执行文件,Chrome 优先,全部未命中再探测 Edge
func findChromeExecutable() (string, error) {
if common.IsWindows() {
candidates := []string{
`C:\Program Files\Google\Chrome\Application\chrome.exe`,
`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local", "Google", "Chrome", "Application", "chrome.exe"),
`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
// 按用户安装的 Edge 不落在 Program Files,LOCALAPPDATA 下才有
filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Edge", "Application", "msedge.exe"),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
for _, name := range []string{"chrome.exe", "msedge.exe"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
} else {
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
}
return "", fmt.Errorf("未找到 Chrome 或 Edge 浏览器,导出 PDF 需要安装其一")
}
// appendExportLog 追加一行PDF导出诊断日志到 %USERPROFILE%\.u-desk\logs\pdf-export.log
// 临时诊断插桩:用户反馈导出转圈即停且错误无处可查,用于收口记录导出链路每个返回点,定位后整体移除
// 写日志自身失败静默,不向导出主链路传播错误
func appendExportLog(msg string) {
home := os.Getenv("USERPROFILE")
if home == "" {
return
}
f, err := os.OpenFile(filepath.Join(home, ".u-desk", "logs", "pdf-export.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05"), msg)
}
// ExportHtmlFileToPDF 将本地HTML文件加载渲染后导出为PDF
func (api *PdfAPI) ExportHtmlFileToPDF(srcPath string, saveName string) (resp *PdfExportResponse, err error) {
// 临时诊断插桩:defer 收口记录每个 error 返回点与最终成功结果
defer func() {
if err != nil {
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF error=%s", err.Error()))
return
}
if resp != nil {
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF ok path=%s size=%d", resp.Path, resp.Size))
}
}()
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF entry srcPath=%q saveName=%q", srcPath, saveName))
// 只接受实际存在的 html 文件,避免把任意路径交给 Chrome 渲染
info, err := os.Stat(srcPath)
if err != nil {
return nil, fmt.Errorf("HTML文件不存在: %s", srcPath)
}
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF stat ok size=%d isDir=%v", info.Size(), info.IsDir()))
if info.IsDir() {
return nil, fmt.Errorf("目标路径是目录,不是HTML文件: %s", srcPath)
}
ext := strings.ToLower(filepath.Ext(srcPath))
if ext != ".html" && ext != ".htm" {
return nil, fmt.Errorf("仅支持 .html/.htm 文件,当前扩展名: %s", ext)
}
// 保存名兜底:空名用源文件名,缺扩展补 .pdf,取 Base 防止路径片段拼进桌面目录
if strings.TrimSpace(saveName) == "" {
saveName = filepath.Base(srcPath)
}
if !strings.HasSuffix(strings.ToLower(saveName), ".pdf") {
saveName += ".pdf"
}
saveName = filepath.Base(saveName)
pdfBuf, err := api.generatePDFFromLocalFile(srcPath)
if err != nil {
return nil, err
}
saveDir := api.resolveExportDir()
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %v", err)
}
savePath := filepath.Join(saveDir, saveName)
if err := os.WriteFile(savePath, pdfBuf, 0644); 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
}
// generatePDFFromLocalFile 用 headless Chrome 加载本地HTML文件并打印为PDF
func (api *PdfAPI) generatePDFFromLocalFile(srcPath string) (buf []byte, err error) {
start := time.Now()
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile start srcPath=%q", srcPath))
// 临时诊断插桩:defer 收口记录结束耗时与结果
defer func() {
if err != nil {
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile end error=%s elapsed=%s", err.Error(), time.Since(start)))
return
}
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile end ok bytes=%d elapsed=%s", len(buf), time.Since(start)))
}()
chromePath, err := findChromeExecutable()
if err != nil {
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile findChromeExecutable error=%s", err.Error()))
return nil, err
}
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile browser hit=%q", chromePath))
absPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("解析文件路径失败: %v", err)
}
// file URL 需整体转义,含中文和空格的路径才能正确导航
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(absPath)}).String()
// 外部HTML常引用图片等资源,加载时间给得比Markdown通道更宽裕
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
opts := []chromedp.ExecAllocatorOption{
chromedp.ExecPath(chromePath),
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),
}
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
chromeCtx, chromeCancel := chromedp.NewContext(allocCtx)
defer chromeCancel()
err = chromedp.Do(chromeCtx,
chromedp.Navigate(fileURL),
// 先确保新文档的 body 可查询,后续 JS 才会落在正确文档上
chromedp.WaitReady("body"),
// PrintToPDF 不等图片资源,必须等 load 事件完成后才打印
chromedp.Func(func(ctx context.Context, t *chromedp.Target) error {
yes := true
res, err := cdp.Call(ctx, t, runtime.Evaluate, runtime.EvaluateParams{
Expression: `new Promise(resolve => {
if (document.readyState === 'complete') resolve(true)
else window.addEventListener('load', () => resolve(true))
})`,
AwaitPromise: &yes,
ReturnByValue: &yes,
})
if err != nil {
return err
}
if res.ExceptionDetails != nil {
return fmt.Errorf("页面加载等待 JS 执行异常: %v", res.ExceptionDetails)
}
return nil
}),
chromedp.Func(func(ctx context.Context, t *chromedp.Target) error {
// 新版 cdproto:Command 为变量,参数经结构体字面量由 cdp.Call 传入
yes, zero := true, 0.0
res, printErr := cdp.Call(ctx, t, page.PrintToPDF, page.PrintToPDFParams{
PrintBackground: &yes,
MarginTop: &zero,
MarginBottom: &zero,
MarginLeft: &zero,
MarginRight: &zero,
PaperWidth: 210.0 / 25.4, // CDP 纸张单位是英寸,A4 宽 210mm
PaperHeight: 297.0 / 25.4,
})
if printErr != nil {
return printErr
}
buf = res.Data
return nil
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp执行失败: %v", err)
}
if len(buf) == 0 {
return nil, fmt.Errorf("PDF生成结果为空")
}
return buf, nil
}
// pdfExportDirConfigKey 导出目录的配置存储键
const pdfExportDirConfigKey = "pdf_export_dir"
// getConfiguredExportDir 读取配置的导出目录,未设置或读取出错返回空串(走默认桌面)
func (api *PdfAPI) getConfiguredExportDir() string {
db, err := storage.InitFast()
if err != nil {
return ""
}
var config models.AppConfig
if err := db.Where("`key` = ?", pdfExportDirConfigKey).First(&config).Error; err != nil {
return ""
}
return config.Value
}
// resolveExportDir 解析本次导出的落盘目录:配置目录不可用时回退桌面,不中断导出
func (api *PdfAPI) resolveExportDir() string {
if dir := api.getConfiguredExportDir(); dir != "" {
if _, err := os.Stat(dir); err == nil || os.MkdirAll(dir, 0755) == nil {
return dir
}
}
return api.getDesktopDirectory()
}
// GetPDFExportDir 获取 PDF 导出目录配置(空串=未设置,走默认桌面)
func (api *PdfAPI) GetPDFExportDir() string {
return api.getConfiguredExportDir()
}
// SavePDFExportDir 保存 PDF 导出目录配置(空串=恢复默认)
func (api *PdfAPI) SavePDFExportDir(dir string) error {
db, err := storage.InitFast()
if err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
var config models.AppConfig
err = db.Where("`key` = ?", pdfExportDirConfigKey).First(&config).Error
if err == gorm.ErrRecordNotFound {
config = models.AppConfig{Key: pdfExportDirConfigKey, Value: dir, Description: "PDF 导出目录"}
return db.Create(&config).Error
} else if err != nil {
return fmt.Errorf("查询配置失败: %w", err)
}
config.Value = dir
return db.Save(&config).Error
}
// 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 "."
}