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系统