新增: 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
+26
View File
@@ -51,6 +51,32 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
}, nil }, 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保存目录 // SelectPDFSaveDirectory 选择PDF保存目录
func (a *App) SelectPDFSaveDirectory() (string, error) { func (a *App) SelectPDFSaveDirectory() (string, error) {
pdfAPI, err := a.requirePdfAPI() pdfAPI, err := a.requirePdfAPI()
+9
View File
@@ -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文件 * ExportPDF 导出PDF文件
*/ */
@@ -210,6 +210,17 @@
<template #icon><icon-save /></template> <template #icon><icon-save /></template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<!-- PDF 导出按钮(仅预览模式显示,后端 Chrome 渲染输出到桌面) -->
<a-tooltip v-if="!config.isEditMode" position="left" content="导出 PDF">
<a-button
type="outline"
size="small"
:loading="htmlExporting"
@click="handleExportHtmlPDF"
>
<template #icon><icon-file-pdf /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 --> <!-- 预览/编辑切换按钮 -->
<a-tooltip <a-tooltip
position="left" position="left"
@@ -440,6 +451,7 @@ import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandle
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers' import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview' import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events' import { On, Off } from '@wailsio/events'
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager' import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system' import { listDir, readFile } from '@/api/system'
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview' import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
@@ -528,32 +540,39 @@ const getFileTypeIcon = (filename: string): string => {
// HTML 预览 URL(OSS 需要先下载到本地再预览) // HTML 预览 URL(OSS 需要先下载到本地再预览)
const htmlPreviewUrl = ref('') const htmlPreviewUrl = ref('')
// 导出 PDF 需要本地可访问的源文件路径:远程文件取缓存副本,本地文件即原路径
const htmlPreviewLocalPath = ref('')
watch( watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile], () => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => { async ([path, isHtml]) => {
htmlPreviewUrl.value = '' htmlPreviewUrl.value = ''
htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return if (!path || !isHtml) return
// 下载到临时目录后是本地文件,统一用本地文件服务器 // 下载到临时目录后是本地文件,统一用本地文件服务器
const base = getFileServerBaseURL() const base = getFileServerBaseURL()
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}` 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()) { if (connectionManager.isRemote()) {
try { try {
const transport = connectionManager.getTransport() const transport = connectionManager.getTransport()
if (transport?.downloadSiteForPreview) { if (transport?.downloadSiteForPreview) {
const tempPath = await transport.downloadSiteForPreview(path) const tempPath = await transport.downloadSiteForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath) applyLocalPath(tempPath)
} else if (transport?.downloadForPreview) { } else if (transport?.downloadForPreview) {
const tempPath = await transport.downloadForPreview(path) const tempPath = await transport.downloadForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath) applyLocalPath(tempPath)
} }
} catch (e) { } catch (e) {
console.warn('[HTML预览] 下载失败:', path, e) console.warn('[HTML预览] 下载失败:', path, e)
} }
} else { } else {
htmlPreviewUrl.value = makeUrl(path) applyLocalPath(path)
} }
}, },
{ immediate: true } { 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 图表 // 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => { watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式 // 从编辑模式切换到预览模式
+169 -15
View File
@@ -4,12 +4,15 @@ import (
"context" "context"
"fmt" "fmt"
"html" "html"
"net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"u-desk/internal/common" "u-desk/internal/common"
@@ -275,22 +278,12 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
chromedp.Flag("disable-notifications", true), chromedp.Flag("disable-notifications", true),
} }
// 在Windows上设置Chrome路径 // 浏览器路径统一走 findChromeExecutable,Chrome 与 Edge 任一可用即可
if common.IsWindows() { browserPath, err := findChromeExecutable()
// 常见的Windows Chrome路径 if err != nil {
chromePaths := []string{ return nil, err
"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
}
}
} }
opts = append(opts, chromedp.ExecPath(browserPath))
// 创建执行分配器上下文 // 创建执行分配器上下文
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...) allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
@@ -348,6 +341,167 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
return buf, nil 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 获取用户桌面目录 // getDesktopDirectory 获取用户桌面目录
func (api *PdfAPI) getDesktopDirectory() string { func (api *PdfAPI) getDesktopDirectory() string {
// Windows系统 // Windows系统