- Markdown通道file URL整体转义,中文空格临时路径可导航 - bindings同步删除已清理的目录选择死代码 - 导出PDF链路插桩日志落.pdf-export.log,临时诊断定位后移除
570 lines
16 KiB
Go
570 lines
16 KiB
Go
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)
|
||
}
|
||
|
||
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 "."
|
||
} |