新增: HTML预览导出PDF并兼容Edge环境

- chromedp 通道新增源文件入口,file:// 加载原始HTML
- 相对资源在原目录天然解析,printBackground 保背景色
- 预览工具栏新增导出按钮,默认文件名取当前HTML名
- 远程文件复用站点缓存路径,本地远程同一命令
- 浏览器探测扩Chrome+Edge双查,Markdown通道重复探测归一
This commit is contained in:
lxy
2026-09-17 16:24:46 +08:00
parent 54b3f03d1f
commit e87ffe1d0c
4 changed files with 251 additions and 18 deletions
+169 -15
View File
@@ -4,12 +4,15 @@ 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"
@@ -275,22 +278,12 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
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
}
}
// 浏览器路径统一走 findChromeExecutable,Chrome 与 Edge 任一可用即可
browserPath, err := findChromeExecutable()
if err != nil {
return nil, err
}
opts = append(opts, chromedp.ExecPath(browserPath))
// 创建执行分配器上下文
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
@@ -348,6 +341,167 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
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`,
}
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 需要安装其一")
}
// ExportHtmlFileToPDF 将本地HTML文件加载渲染后导出为PDF
func (api *PdfAPI) ExportHtmlFileToPDF(srcPath string, saveName string) (*PdfExportResponse, error) {
// 只接受实际存在的 html 文件,避免把任意路径交给 Chrome 渲染
info, err := os.Stat(srcPath)
if err != nil {
return nil, fmt.Errorf("HTML文件不存在: %s", srcPath)
}
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) ([]byte, error) {
chromePath, err := findChromeExecutable()
if err != nil {
return nil, err
}
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()
var buf []byte
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系统