From e87ffe1d0c912463a7d5b6ba5322767ecac6a4e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com>
Date: Thu, 17 Sep 2026 16:22:35 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20HTML=E9=A2=84=E8=A7=88?=
=?UTF-8?q?=E5=AF=BC=E5=87=BAPDF=E5=B9=B6=E5=85=BC=E5=AE=B9Edge=E7=8E=AF?=
=?UTF-8?q?=E5=A2=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- chromedp 通道新增源文件入口,file:// 加载原始HTML
- 相对资源在原目录天然解析,printBackground 保背景色
- 预览工具栏新增导出按钮,默认文件名取当前HTML名
- 远程文件复用站点缓存路径,本地远程同一命令
- 浏览器探测扩Chrome+Edge双查,Markdown通道重复探测归一
---
app_pdf.go | 26 +++
frontend/bindings/u-desk/app.ts | 9 +
.../FileSystem/components/FileEditorPanel.vue | 50 ++++-
internal/api/pdf_api.go | 184 ++++++++++++++++--
4 files changed, 251 insertions(+), 18 deletions(-)
diff --git a/app_pdf.go b/app_pdf.go
index c007e65..5f5e724 100644
--- a/app_pdf.go
+++ b/app_pdf.go
@@ -51,6 +51,32 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
}, nil
}
+// ExportHtmlFileToPDF 将HTML文件导出为PDF
+func (a *App) ExportHtmlFileToPDF(path string, saveName string) (map[string]interface{}, error) {
+ pdfAPI, err := a.requirePdfAPI()
+ if err != nil {
+ return map[string]interface{}{
+ "success": false,
+ "message": err.Error(),
+ }, err
+ }
+
+ result, err := pdfAPI.ExportHtmlFileToPDF(path, saveName)
+ if err != nil {
+ return map[string]interface{}{
+ "success": false,
+ "message": err.Error(),
+ }, err
+ }
+
+ return map[string]interface{}{
+ "success": result.Success,
+ "message": result.Message,
+ "path": result.Path,
+ "size": result.Size,
+ }, nil
+}
+
// SelectPDFSaveDirectory 选择PDF保存目录
func (a *App) SelectPDFSaveDirectory() (string, error) {
pdfAPI, err := a.requirePdfAPI()
diff --git a/frontend/bindings/u-desk/app.ts b/frontend/bindings/u-desk/app.ts
index c7a5952..ad0a410 100644
--- a/frontend/bindings/u-desk/app.ts
+++ b/frontend/bindings/u-desk/app.ts
@@ -133,6 +133,15 @@ export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any
});
}
+/**
+ * ExportHtmlFileToPDF 将HTML文件导出为PDF
+ */
+export function ExportHtmlFileToPDF(path: string, saveName: string): $CancellablePromise<{ [_ in string]?: any }> {
+ return $Call.ByID(3012957793, path, saveName).then(($result: any) => {
+ return $$createType2($result);
+ });
+}
+
/**
* ExportPDF 导出PDF文件
*/
diff --git a/frontend/src/components/FileSystem/components/FileEditorPanel.vue b/frontend/src/components/FileSystem/components/FileEditorPanel.vue
index 2afbfc2..8b91c9e 100644
--- a/frontend/src/components/FileSystem/components/FileEditorPanel.vue
+++ b/frontend/src/components/FileSystem/components/FileEditorPanel.vue
@@ -210,6 +210,17 @@
+
+
+
+
+
+
{
// HTML 预览 URL(OSS 需要先下载到本地再预览)
const htmlPreviewUrl = ref('')
+// 导出 PDF 需要本地可访问的源文件路径:远程文件取缓存副本,本地文件即原路径
+const htmlPreviewLocalPath = ref('')
watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => {
htmlPreviewUrl.value = ''
+ htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return
// 下载到临时目录后是本地文件,统一用本地文件服务器
const base = getFileServerBaseURL()
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
+ const applyLocalPath = (localPath: string) => {
+ htmlPreviewLocalPath.value = localPath
+ htmlPreviewUrl.value = makeUrl(localPath)
+ }
if (connectionManager.isRemote()) {
try {
const transport = connectionManager.getTransport()
if (transport?.downloadSiteForPreview) {
const tempPath = await transport.downloadSiteForPreview(path)
- htmlPreviewUrl.value = makeUrl(tempPath)
+ applyLocalPath(tempPath)
} else if (transport?.downloadForPreview) {
const tempPath = await transport.downloadForPreview(path)
- htmlPreviewUrl.value = makeUrl(tempPath)
+ applyLocalPath(tempPath)
}
} catch (e) {
console.warn('[HTML预览] 下载失败:', path, e)
}
} else {
- htmlPreviewUrl.value = makeUrl(path)
+ applyLocalPath(path)
}
},
{ immediate: true }
@@ -897,6 +916,31 @@ const handleExportCsvPDF = async () => {
)
}
+// HTML 文件导出 PDF:交给后端 headless Chrome 渲染,绕开浏览器打印对话框
+const htmlExporting = ref(false)
+const handleExportHtmlPDF = async () => {
+ const srcPath = htmlPreviewLocalPath.value
+ if (!srcPath) {
+ Message.error('HTML 预览尚未就绪,请稍候重试')
+ return
+ }
+ const baseName = (props.config.currentFileName || 'export').replace(/\.[^.]+$/, '')
+ htmlExporting.value = true
+ try {
+ const res = await ExportHtmlFileToPDF(srcPath, baseName)
+ if (res?.success) {
+ Message.success(`PDF 已导出到: ${res.path}`)
+ } else {
+ Message.error(res?.message || 'PDF 导出失败')
+ }
+ } catch (e: any) {
+ // 后端约定的错误文案随异常抛出,无 Chrome 时向用户展示安装提示
+ Message.error(e?.message || 'PDF 导出失败')
+ } finally {
+ htmlExporting.value = false
+ }
+}
+
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式
diff --git a/internal/api/pdf_api.go b/internal/api/pdf_api.go
index 2b1c0e2..09c3567 100644
--- a/internal/api/pdf_api.go
+++ b/internal/api/pdf_api.go
@@ -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系统