package api import ( "context" "fmt" "html" "net/url" "os" "os/exec" "path/filepath" "strings" "time" "github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/runtime" "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.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 := `
%s
%s ` // 标题处理 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 = "

Markdown 解析失败

" } 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.Run(chromeCtx, chromedp.Navigate(fileURL), // 等待页面加载完成 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) } 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.getDesktopDirectory() 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) } // 成功弹窗几秒即逝用户难感知产物位置,改用资源管理器选中 PDF 明示落盘位置;失败静默不影响导出结果 // 用户 PATH 无 C:\Windows 根目录,explorer 走 LookPath 解析不到,必须用 WINDIR 拼绝对路径 if common.IsWindows() { exePath := filepath.Join(os.Getenv("WINDIR"), "explorer.exe") if selectErr := exec.Command(exePath, "/select,", savePath).Start(); selectErr != nil { appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF explorer select error=%s", selectErr.Error())) } } 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.Run(chromeCtx, chromedp.Navigate(fileURL), // 先确保新文档的 body 可查询,后续 JS 才会落在正确文档上 chromedp.WaitReady("body"), // PrintToPDF 不等图片资源,必须等 load 事件完成后才打印 chromedp.ActionFunc(func(ctx context.Context) error { _, exp, err := runtime.Evaluate(`new Promise(resolve => { if (document.readyState === 'complete') resolve(true) else window.addEventListener('load', () => resolve(true)) })`).WithAwaitPromise(true).WithReturnByValue(true).Do(ctx) if exp != nil { return exp } return err }), chromedp.ActionFunc(func(ctx context.Context) error { printToPDF := page.PrintToPDF(). WithPrintBackground(true). WithLandscape(false). WithMarginTop(0). WithMarginBottom(0). WithMarginLeft(0). WithMarginRight(0). WithPaperWidth(210.0/25.4). // CDP 纸张单位是英寸,A4 宽 210mm WithPaperHeight(297.0/25.4) var printErr error buf, _, printErr = printToPDF.Do(ctx) return printErr }), ) if err != nil { return nil, fmt.Errorf("chromedp执行失败: %v", err) } if len(buf) == 0 { return nil, fmt.Errorf("PDF生成结果为空") } 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 "." }