Files
u-desk/internal/api/pdf_api.go
T
lxy e8ccb4280b 优化: PDF导出成功后打开所在位置选中文件
- 导出落盘后explorer选中PDF,不再只靠一闪而过的弹窗
- 成功文案明确指向导出位置
- explorer用WINDIR绝对路径,不依赖PATH
2026-09-28 09:33:53 +08:00

579 lines
17 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/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 := `<!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.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 "."
}