Web端 和 Electron桌面端 PDF 下载、预览和打印?看这一篇就够了

4,953 阅读5分钟

前言

基于Vue的 web端和桌面端文件下载和预览总结。项目使用的vue版本为 2.6.10,vue-cli版本为 3.12.1,node版本为 v14.17.5,electron 版本 12.0.0。

注意:本文方法都是基于后端提供pdf资源的情形!

※注:本文代码区域每行开头的“+”表示新增,“-”表示删除,“M”表示修改;代码中的“...”表示省略。

1 Web 端

1.1 下载到本地

思路:

一般来说后端会返回二进制的 pdf 文件,这里以原生的ajax请求为例,指定xhr.responseType = 'blob',接收到Blob 对象。将 Blob 对象 通过 window.URL.createObjectURL(blob) 转化为 URL,再将 URL 赋值给 a 标签的 link属性,调用 a.click() 即可下载。

方法和属性解释:

  • axios.defaults.baseURL:请求的基础路径
  • QS.stringify 格式化方法

代码示例:

// url:接口地址,
// query:请求参数, query格式: { testId: xxx, formatId: aaa}
function download(url, query) {

    let xhr = new XMLHttpRequest()
    xhr.open('GET', `${axios.defaults.baseURL}${url}?${QS.stringify(query)}`, true)
    // AJAX 请求时,如果指定responseType属性为blob,下载下来的就是一个 Blob 对象
    xhr.responseType = 'blob'
    xhr.setRequestHeader('Access-Token', storage.session.get('token'))
    xhr.onload = function() {
        if (this.status == 200) {

            let blob = this.response
            //生成URL
            let href = window.URL.createObjectURL(blob)
            let link = document.createElement('a')
            link.download = '波形.pdf'
            link.href = href
            link.click()
            window.URL.revokeObjectURL(href)
        }
    }
    xhr.send(null)
}

如果后端返回的是 base64 的数据可以通过下面的方法先转化为 URL :

     /**
     * base64 转 URL, 原理:利用URL.createObjectURL为 Blob 对象创建临时的URL
     * @param {String} b64data base64 数据
     * @param {String} contentType 要转化的数据类型
     * @param {Number} sliceSize 《有待学习》
     */
    base64ToURL({ b64data = '', contentType = '', sliceSize = 512 } = {}) {
        return new Promise((resolve, reject) => {
            // 使用 atob() 方法将数据解码
            let byteCharacters = atob(b64data)
            let byteArrays = []
            for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
                let slice = byteCharacters.slice(offset, offset + sliceSize)
                let byteNumbers = []
                for (let i = 0; i < slice.length; i++) {
                    byteNumbers.push(slice.charCodeAt(i))
                }
                // 8 位无符号整数值的类型化数组。内容将初始化为 0。
                // 如果无法分配请求数目的字节,则将引发异常。
                byteArrays.push(new Uint8Array(byteNumbers))
            }
            let result = new Blob(byteArrays, {
                type: contentType,
            })
            result = Object.assign(result, {
                // 这里一定要处理一下 URL.createObjectURL
                // 该方法生成的 URL格式: blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1
                preview: window.URL.createObjectURL(result),
                name: `XXX.png`,
            })
            resolve(result)
        })
    }

1.2 预览

这里选用 pdf.js 插件来进行预览,

1.2.1 pdf.js 插件下载

官网地址:mozilla.github.io/pdf.js/gett…

下载 Prebuilt (for older browsers) 对应的稳定版本,解压后有一个build和web文件夹,这里主要用到 web 文件夹。

1.2.2 pdf.js 插件使用

pdf 插件所处的目录结构

(略)
|- /public
   |- pdf
   	  |- build
	  |- web
		 ...
		 |- viewer.html
		 ...
|- /src
  (略)

这里以 Vue 为例来说明,其他框架用法类似。

xxx.vue 组件中用法:

将下载完后的 blob 或 base64 格式的文件 通过 window.URL.createObjectURL() 转化为 URL 赋值 给 下方的 pdfURL 即可。

...
<iframe
       v-if="pdfURL"
       width="100%"
       height="100%"
       scrolling="no"
       :src="`/pdf/web/viewer.html?file=${encodeURIComponent(pdfUrl)}`"
       class="pdf_preview"
></iframe>
...

1.3 打印

1.3.1 pdf.js 插件打印

此方法点击pdf.js 插件头部的打印图标即可实现打印

1.3.2 原生方法打印

原理:将下载后的 Blob 对象通过 window.URL.createObjectURL(blob) 转化为 URL,将URL 赋值给 iframe.src,然后通过 iframe.contentWindow.print() 实现打印。

完整代码如下:

// url:接口地址,query:查询参数
function print(url, query) {
    let xhr = new XMLHttpRequest()

    xhr.open('GET', `${axios.defaults.baseURL}${url}?${QS.stringify(query)}`, true)
    xhr.responseType = 'blob'
    xhr.setRequestHeader('Access-Token', storage.session.get('token'))
    xhr.onload = function() {
        if (this.status == 200) {
            // let blob = new Blob([this.response], {
            //     type: 'application/pdf;charset=utf-8',
            // })
            let blob = this.response
            //生成URL
            let href = window.URL.createObjectURL(blob)
            // console.log(href)
            Vue.nextTick(() => {
                let EleIframe = document.querySelector('.iframe_print')
                if (EleIframe) {
                    EleIframe.remove()
                }

                let iframe = document.createElement('iframe')
                iframe.src = href
                iframe.classList.add('iframe_print')
                iframe.style.display = 'none'
                document.body.append(iframe)
                // iframe.contentWindow.focus()
                iframe.contentWindow.print()

                window.URL.revokeObjectURL(href)
            })
        }
    }
    xhr.send()
}

1.3.3 静默打印

在 web 端实现静默打印需要借助 控件,原理就是点击打印时 用 http 或 websocket 和打印控件进行通信来实现静默打印。此方法我暂未尝试过,有兴趣的小伙伴可以查询资料后尝试一下。

2 Electron 桌面端

2.1 下载到本地

思路:

一般来说后端会返回二进制的 pdf 文件,这里以原生的ajax请求为例,指定xhr.responseType = 'blob',接收到Blob 对象。然后通过 FileReader 将blob对象转化为 base64,再通过 Buffer.from(data, 'base64') 将base64 转化为Buffer,最后通过 dialog.showSaveDialog 和 fs.writeFile 下载到指定位置即可。

方法和属性解释:

  • axios.defaults.baseURL:请求的基础路径
  • QS.stringify 格式化方法

代码示例:

const { remote } = require('electron')
const dialog = remote.dialog
const fs = require('fs')

// 将浏览器中的 blob 转化为 nodejs 中的 buffer 对象
blobToBuffer(blob) {
    return new Promise((resolve, reject) => {
        const fileReader = new FileReader()
        fileReader.onload = () => {
            resolve(Buffer.from(fileReader.result))
        }
        fileReader.onerror = reject
        fileReader.readAsArrayBuffer(blob)
    })
}

...
// url:接口地址,
// query:请求参数, query格式: { testId: xxx, formatId: aaa}
function download(url, query) {
    var xhr = new XMLHttpRequest()
    xhr.open('GET', `${axios.defaults.baseURL}${url}?${QS.stringify(query)}`, true)
    // AJAX 请求时,如果指定responseType属性为blob,下载下来的就是一个 Blob 对象
    xhr.responseType = 'blob'
    xhr.onload = async function () {
        if (this.status == 200) {
            
            let buffer
            try {
                buffer = await blobToBuffer(this.response)
            } catch (error) {
                console.log(error)
            }
            
            dialog
                .showSaveDialog({
                    properties: ['openFile', 'openDirectory'],
                    title: '保存文件',
                    defaultPath: `${+new Date()}.pdf`,
                    filters: [{ name: '.pdf', extensions: ['pdf'] }],
                })
                .then((res) => {
                    if (res.filePath) {
                        // 将文件写入到手动选择的路径下
                        fs.writeFile(res.filePath, buffer, 'binary', (err) => {
                            if (err) {
                                // 向渲染进程发送消息通知失败
                                e.sender.send('reply', err)
                            } else {
                                // 向渲染进程发送消息通知成功
                                e.sender.send('reply', res.filePath)
                            }
                        })
                    }
                })
        } 
    }
    xhr.send(null)
}
...

2.2 预览

这里选用 pdf.js 插件来进行预览,

2.2.1 pdf.js 插件下载

官网地址:mozilla.github.io/pdf.js/gett…

下载 Prebuilt (for older browsers) 对应的稳定版本,解压后有一个build和web文件夹,这里主要用到 web 文件夹。

2.2.2 pdf.js 插件使用

pdf 插件所处的目录结构

(略)
|- /public
   |- pdf
   	  |- build
	  |- web
		 ...
		 |- viewer.html
		 ...
|- /src
  (略)

首先将 pdf 文件下载到指定的目录下, 以写入到 /public/data 目录下为例

writeFile.js 文件:

const {  remote } = require('electron')
const app = remote.app
const fs = require('fs')
const path = require('path')
...

// 将浏览器中的 blob 转化为 nodejs 中的 buffer 对象
blobToBuffer(blob) {
    return new Promise((resolve, reject) => {
        const fileReader = new FileReader()
        fileReader.onload = () => {
            resolve(Buffer.from(fileReader.result))
        }
        fileReader.onerror = reject
        fileReader.readAsArrayBuffer(blob)
    })
}

// 预览pdf时调用
// url:接口地址,
// query:请求参数, query格式: { testId: xxx, formatId: aaa}
function downloadPreview(url, query) {
    return new Promise((resolve, reject) => {
        var xhr = new XMLHttpRequest()
        xhr.open('GET', `${axios.defaults.baseURL}${url}?${QS.stringify(query)}`, true)
        xhr.responseType = 'blob'
        xhr.setRequestHeader('Access-Token', sessionStorage.getItem('token'))
        
        xhr.onload = async function () {
            if (this.status == 200) {
                let buffer
                try {
                    buffer = await blobToBuffer(this.response)
                } catch (error) {
                    console.log(error)
                }

                let filePath
                // 开发环境
                if (process.env.NODE_ENV === 'development') {
                    filePath = path.join(__static, 'data/preview.pdf')
                    // 生产环境
                } else {
                    filePath = path.join(app.getAppPath(), 'data/preview.pdf')
                }

                fs.stat(filePath, (err, stats) => {
                    if (!err) {
                        // console.log(stats);
                        // console.log(stats.isFile());
                        // 文件存在时先删除再写入
                        if (stats.isFile()) {
                            let res = fs.unlinkSync(filePath)
                            if (!res) {
                                fs.writeFile(filePath, buffer, 'binary', (err) => {
                                    if (err) {
                                        // console.log(err)
                                        reject(err)
                                    } else {
                                        // console.log('保存成功')
                                        resolve('../../data/preview.pdf')
                                    }
                                })
                            }
                        }
                    } else {
                        // 文件不存在时直接写入
                        fs.writeFile(filePath, buffer, 'binary', (err) => {
                            if (err) {
                                // console.log(err)
                                reject(err)
                            } else {
                                // console.log('保存成功')
                                resolve('../../data/preview.pdf')
                            }
                        })
                    }
                })
            }
        }
        
        xhr.send(null)
    })
}
...
export {downloadPreview}

2.2.3 关于写入路径的坑

  • __static(nsis 中 asar 的配置为false)

    开发环境下的值:C:\Users\DELL\Desktop\项目名\public

    生产环境下的值(electron 11):C:\Program Files (x86)\项目名\resources

    生产环境下的值(electron 12):C:\Program Files (x86)\项目名\resources\app.asar

  • app.getAppPath()

    开发环境下的值:C:\Users\DELL\Desktop\项目名\dist_electron

    生产环境下的值:C:\Program Files (x86)\项目名\resources\app

因为 __static 在生产环境下有坑,所以在生产环境中获取路径建议还是用 app.getAppPath()

请参考:

let filePath
 // 开发环境
 if (process.env.NODE_ENV === 'development') {
      filePath = path.join(__static, 'data/preview.pdf')
 // 生产环境
    } else {
      filePath = path.join(app.getAppPath(), 'data/preview.pdf')
    }

这里以 Vue 为例来说明,其他框架用法类似。

xxx.vue 组件中用法:

...
<iframe
       v-if="pdfUrl"
       width="100%"
       height="100%"
       scrolling="no"
       :src="`/pdf/web/viewer.html?file=${encodeURIComponent(pdfUrl)}`"
       class="pdf_preview"
></iframe>
...
import { downloadPreview } from '@/xxx/writeFile.js'
...
// 将pdf 文件对应的路径赋值给 pdfUrl后,就能预览下载完成后的 pdf 文件了
downloadPreview('xxx/xxx', {id: xxx}).then(res => {this.pdfUrl = res})
...

2.3 打印

2.3.1 pdf.js 插件打印

此方法点击pdf.js 插件头部的打印图标即可实现打印

2.3.2 原生方法打印

参照 1.3.2节 方法

2.3.3 静默打印

原理:将下载后的 Blob 对象转化为 nodejs 中的 buffer,再通过 fs.writeFile 写入到指定的 filePath,然后 通过nodejs 的 childProcess 模块用命令方式执行SumatraPDF 插件来打印,打印完成通过 fs.unlink 删除下载的pdf临时文件。

Vue 组件中的 js 代码如下:

this.$api.exportReport 为一个http请求,blob 为返回的 pdf 对应的 blob对象,ipc.send('printPDF', filePath) 为渲染进程和主进程通信的方法。

 const fs = require('fs')
const path = require('path')
import { Buffer } from 'buffer'
...

// 将浏览器中的 blob 转化为 nodejs 中的 buffer 对象
blobToBuffer(blob) {
    return new Promise((resolve, reject) => {
        const fileReader = new FileReader()
        fileReader.onload = () => {
            resolve(Buffer.from(fileReader.result))
        }
        fileReader.onerror = reject
        fileReader.readAsArrayBuffer(blob)
    })
}

async printPdf() {
    // 首先请求到 pdf 的blob资源, getPdfBlob 方法我这里为写出来,根据业务去请求即可。
    const blob = await getPdfBlob()
    // blob 转化为 buffer
    const buffer = await blobToBuffer(blob)
    // 写入目录
    const dirPath = this.$tools.getDownloadsPath()
    // 写入文件名
    const fileName = `print.pdf`
    // 写入路径
    const filePath = `${dirPath}\\${fileName}`
    // 写入成功后 通知主进程打印
    this.$tools
        .downloadPdfToPrint(buffer, dirPath, filePath)
        .then((res) => {
            ipcRenderer.send('printPDF', res.filePath)
        })
}

// 打印 pdf 文件
printPdf()

...

主进程中的 printPDF 事件如下(这里是采用默认打印机打印,关于打印机的更多需求请读者自行研究,我自己也是浅尝辄止):

const fs = require('fs')
const path = require('path')
const childProcess = require('child_process')
import process from 'process'
const { ipcMain,  app, BrowserWindow} = require('electron')

...

ipcMain.on('printPDF', (event, filePath) => {
    switch (process.platform) {
        case 'win32':
            // child_process.exec(command[, options][, callback])
            // command <string>: 要运行的命令,参数以空格分隔。
            // options.cwd <string> | <URL> 子进程的当前工作目录。 默认值: process.cwd()。
            // options.windowsHide <boolean> 隐藏通常在 Windows 系统上创建的子进程控制台窗口。 默认值: false。
            // saveLog(`当前打印pdf文件的路径: '${filePath}\n`)
            childProcess.exec(
                `SumatraPDF.exe -print-to-default -print-settings "paper=A4" "${filePath}"`,
                {
                    windowsHide: true,
                    cwd: path.join(__static, 'SumatraPDF'),
                },
                (e) => {
                    if (e) {
                        event.sender.send(
                            'main-to-renderer',
                            '打印失败',
                            path.join(__static, 'SumatraPDF')
                        )
                        throw e
                    }

                    event.sender.send(
                        'main-to-renderer',
                        '打印成功',
                        path.join(__static, 'SumatraPDF')
                    )
                    /* 打印完成后删除创建的临时文件 */
                    fs.unlink(filePath, (e) => {})
                }
            )
            break

        case 'darwin':
        case 'linux':
            childProcess.exec(
                `SumatraPDF.exe -print-to-default  "${filePath}"`,
                {
                    windowsHide: true,
                    cwd: path.join(__static, 'SumatraPDF'),
                },
                (e) => {
                    if (e) {
                        throw e
                    }
                    /* 打印完成后删除创建的临时文件 */
                    fs.unlink(filePath, (e) => {})
                }
            )
            break

        default:
            throw new Error('Platform not supported.')
    }
})

this.$tools 工具函数如下:

const fs = require('fs')
const path = require('path')
const { app } = require('electron').remote
    
    /**
     * 将 buffer pdf 写入到指定路径,以便打印。
     * @param {buffer} nodejs中的 buffer 对象
     * @returns URL 写入成功后返回写入路径
     */
    downloadPdfToPrint(buffer, dirPath, filePath) {
        const writeFile = (filePath, buffer, resolve, reject) => {
            fs.writeFile(filePath, buffer, 'binary', (err) => {
                if (err) {
                    // console.log(err)
                    reject(err)
                } else {
                    // console.log('保存成功')
                    resolve({ filePath })
                }
            })
        }
        
        return new Promise(async (resolve, reject) => {

            fs.stat(dirPath, (err, stats) => {
                if (!err) {
                    // 走到这里, 表示目录存在
                    if (fs.existsSync(filePath)) {
                        // console.log('electrophysiology.pdf 存在');
                        fs.unlinkSync(filePath)
                        // 先删除pdf再写入
                        writeFile(filePath, buffer, resolve, reject)
                    } else {
                        // console.log('electrophysiology.pdf 不存在');
                        // 直接写入
                        writeFile(filePath, buffer, resolve, reject)
                    }
                } else {
                    // 只要目录不存在,就会走到这里, 先创建目录, 再写pdf
                    fs.mkdir(dirPath, { recursive: true }, (err) => {
                        if (err) {
                            throw err
                        } else {
                            writeFile(filePath, buffer, resolve, reject)
                        }
                    })
                }
            })
        })
    },

    getDownloadsPath() {
        let downloadsPath
        // 开发环境
        if (process.env.NODE_ENV === 'development') {
            downloadsPath = path.join(__static, 'downloads')
            // 生产环境
        } else {
            downloadsPath = path.join(app.getAppPath(), 'downloads')
        }
        return downloadsPath
    }

2.3.4 静默打印踩坑记录

最近在项目中碰到个很奇葩的问题,我项目中打印都是采用A4纸大小,但是有某些后端获取的pdf文件的尺寸小于A4纸大小时(我项目中pdf文档属性中页面大小为:279.4 × 215.9 毫米,此属性在浏览器中可查看到。A4纸大小为297mm × 210mm),静默打印会失败,打印机闪红灯,按一下打印机的ok键就能打印出来了。且非静默打印都是可以直接打印成功的。经多种尝试后,终于定位到问题,在命令行中加入打印设置纸张设为A4大小即可。一开始看文档时找开发者文档去了,没想到在用户文档部分,浪费了很多时间。。。

image.png

SumatraPDF 命令行部分文档地址:www.sumatrapdfreader.org/docs/Comman…

解决方法:将 SumatraPDF.exe -print-to-default "${filePath}" 改为 SumatraPDF.exe -print-to-default -print-settings "paper=A4" "${filePath}" 即可正常打印了

由于本人水平有限,文中如有错误,欢迎在评论区指正。若文章对您有些许帮助,欢迎点赞和关注!