新增: HTML预览导出PDF并兼容Edge环境
- chromedp 通道新增源文件入口,file:// 加载原始HTML - 相对资源在原目录天然解析,printBackground 保背景色 - 预览工具栏新增导出按钮,默认文件名取当前HTML名 - 远程文件复用站点缓存路径,本地远程同一命令 - 浏览器探测扩Chrome+Edge双查,Markdown通道重复探测归一
This commit is contained in:
+26
@@ -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()
|
||||
|
||||
@@ -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文件
|
||||
*/
|
||||
|
||||
@@ -210,6 +210,17 @@
|
||||
<template #icon><icon-save /></template>
|
||||
</a-button>
|
||||
</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
|
||||
position="left"
|
||||
@@ -440,6 +451,7 @@ import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandle
|
||||
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
||||
import { isDirty } from '../composables/useMultiPreview'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { listDir, readFile } from '@/api/system'
|
||||
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
|
||||
@@ -528,32 +540,39 @@ const getFileTypeIcon = (filename: string): string => {
|
||||
|
||||
// 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) => {
|
||||
// 从编辑模式切换到预览模式
|
||||
|
||||
+169
-15
@@ -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系统
|
||||
|
||||
Reference in New Issue
Block a user