纯前端实现导出PDF文件(基于html2canvas和jspdf)

**

需求说明:纯前端的方式,将渲染的页面下载为pdf的形式,使用html2canvas和jspdf

**
下载的pdf:

一、引入插件

javascript 复制代码
// 直接在html页面引入链接的方式
<script src="https://html2canvas.hertzen.com/dist/html2canvas.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/1.5.3/jspdf.min.js"></script>
javascript 复制代码
// npm下载的方式
npm install --save html2canvas
npm install --save jspdf

//引入
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';

二、绘制页面

javascript 复制代码
<template>
    <div class="container">
        <div class="btn" @click="handleDownload">生成pdf</div>
        <div id="pdf-container">
            <h3>这是一个标题</h3>
            <p>
                我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容我是文章内容
            </p>
            <p>你好啊撒卡卡嘎嘎快</p>
            <div>
                <img src="@/assets/image/4.png" alt="" />
            </div>
            <div>
                <img src="@/assets/image/5.png" alt="" />
            </div>
            <div>
                <img src="@/assets/image/11.png" alt="" />
            </div>
        </div>
    </div>
</template>

<script setup>
import htmlToPdf from '@/utils/downPdf' //此为封装的下载方法,后面介绍
const handleDownload = () => {
    const article = document.querySelector('#pdf-container') //获取下载页面的dom元素
    htmlToPdf(article) //调用方法下载
}
</script>

<style scoped>
.container {
    padding: 100px;
    width: 100%;
    height: 100%;
    overflow: auto;
    background: #fff;
}
.btn {
    width: 120px;
    height: 40px;
    line-height: 40px;
    border-radius: 10px;
    text-align: center;
    border: 1px dotted #000;
}
#pdf-container {
    width: 100%;
    height: 100%;
}
p {
    margin: 10px 0;
}
div {
    text-align: center;
}
</style>

三、封装下载方法

javascript 复制代码
//引入插件
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'

//这个方法需要传一个dom元素
const htmlToPdf = async (dom) => {
    // 若传入的 DOM 元素为空,则直接返回
    if (!dom) return

    try {
        // 利用 html2canvas 将 HTML 元素渲染为 canvas 对象
        const canvas = await html2canvas(dom, { scale: 4 }) // 提高清晰度

        // 创建一个新的 jsPDF 实例,设置为纵向 A4 纸大小
        const pdf = new jsPDF('p', 'mm', 'a4')

        // 获取 canvas 的 2D 绘图环境
        const ctx = canvas.getContext('2d')

        // A4 纸的宽度和高度(单位:毫米),预留边距
        const a4w = 190
        const a4h = 280

        // 根据 A4 纸的高度和 canvas 的宽度,计算每页图像应有的像素高度
        const imgHeight = Math.floor((a4h * canvas.width) / a4w)

        // 已渲染内容的高度
        let renderedHeight = 0

        // 循环处理 canvas 内容,分页渲染到 PDF
        while (renderedHeight < canvas.height) {
            // 创建一个新的 canvas 对象,用于处理单页内容
            const page = document.createElement('canvas')
            page.width = canvas.width
            page.height = Math.min(imgHeight, canvas.height - renderedHeight)

            // 从原 canvas 中剪裁出当前页的内容
            const imageData = ctx.getImageData(
                0,
                renderedHeight,
                canvas.width,
                Math.min(imgHeight, canvas.height - renderedHeight)
            )
            page.getContext('2d').putImageData(imageData, 0, 0)

            // 将当前页添加到 PDF 中
            pdf.addImage(
                page.toDataURL('image/jpeg', 1.0),
                'JPEG',
                10,
                5,
                a4w,
                Math.min(a4h, (a4w * page.height) / page.width)
            )

            // 更新已渲染内容的高度
            renderedHeight += imgHeight

            // 如果还有剩余内容,添加新页面
            if (renderedHeight < canvas.height) {
                pdf.addPage()
            }
        }

        // 保存并命名生成的 PDF 文档
        pdf.save('converted.pdf')
    } catch (error) {
        console.error('Error converting HTML to PDF:', error)
    }
}

export default htmlToPdf

四、分页调整

我发现当内容很多时,有图片有文字,分页时,上面代码就不行了,所以做了优化

javascript 复制代码
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'

const htmlToPdf = async (ele) => {
    html2canvas(ele, {
        scale: 2,
    }).then((canvas) => {
        let position = 0 //页面偏移
        const A4Width = 595.28 // A4纸宽度
        const A4Height = 841.89 // A4纸宽
        // 一页PDF可显示的canvas高度
        const pageHeight = (canvas.width * A4Height) / A4Width
        // 未分配到PDF的canvas高度
        let unallottedHeight = canvas.height
        // 新增内容检测方法
        const hasContentInRange = (yPosition) => {
            const ctx = canvas.getContext('2d')
            // 检测上下10px区域
            const yStart = Math.max(0, yPosition - 10)
            const yEnd = Math.min(canvas.height, yPosition + 10)
            const imageData = ctx.getImageData(
                0,
                yStart,
                canvas.width,
                yEnd - yStart
            )

            // 检查透明度通道
            for (let i = 3; i < imageData.data.length; i += 4) {
                if (imageData.data[i] > 30) {
                    // 存在可见内容
                    return true
                }
            }
            return false
        }

        // 修改分页逻辑
        let currentHeight = 0
        const pdf = new jsPDF('', 'pt', [A4Width, A4Height])

        while (currentHeight < canvas.height) {
            let pageEnd = currentHeight + pageHeight

            // 检测分页区域底部上下10px是否有内容
            if (hasContentInRange(pageEnd)) {
                // 找到最近的空白区域
                for (let y = pageEnd; y > currentHeight; y--) {
                    if (!hasContentInRange(y)) {
                        pageEnd = y
                        break
                    }
                }
            }

            // 创建分页canvas
            const sliceCanvas = document.createElement('canvas')
            sliceCanvas.width = canvas.width
            sliceCanvas.height = pageEnd - currentHeight

            const ctx = sliceCanvas.getContext('2d')
            ctx.drawImage(
                canvas,
                0,
                currentHeight,
                canvas.width,
                sliceCanvas.height,
                0,
                0,
                canvas.width,
                sliceCanvas.height
            )

            // 添加PDF页面
            if (currentHeight > 0) pdf.addPage()
            pdf.addImage(
                sliceCanvas.toDataURL('image/jpeg', 1.0),
                'JPEG',
                0,
                0,
                A4Width,
                (sliceCanvas.height * A4Width) / canvas.width
            )

            currentHeight = pageEnd
        }

        pdf.save('下载多页PDF(A4纸).pdf')
    })
}

export default htmlToPdf

上述代码可以导出完整的pdf文件,但是还有个bug,就是处于分割线的内容,有时会被分割为两部分,我暂时还没有解决,待解决后补充

五、无bug版本

javascript 复制代码
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'

/**
 * 生成pdf(处理多页pdf截断问题)
 * @param {Object} param
 * @param {HTMLElement} param.element - 需要转换的dom根节点
 * @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-595
 * @param {number} [param.contentHeaderWidth=550] - 一页pdf的内容宽度,0-595
 * @param {number} [param.contentFooterWidth=550] - 一页pdf的内容宽度,0-595
 * @param {number} [param.contentHeight=800] - 一页pdf的内容高度,0-842
 * @param {string} [param.outputType='save'] - 生成pdf的数据类型,默认是save下载下来,添加了'file'类型,其他支持的类型见http://raw.githack.com/MrRio/jsPDF/master/docs/jsPDF.html#output
 * @param {number} [param.scale=window.devicePixelRatio * 2] - 清晰度控制,canvas放大倍数,默认像素比*2
 * @param {string} [param.direction='p'] - 纸张方向,l横向,p竖向,默认A4纸张
 * @param {string} [param.fileName='document.pdf'] - pdf文件名,当outputType='file'时候,需要加上.pdf后缀
 * @param {number} param.baseY - pdf页内容距页面上边的高度,默认 15px
 * @param {HTMLElement} param.header - 页眉dom元素
 * @param {HTMLElement} param.footer - 页脚dom元素
 * @param {string} [param.isPageMessage=false] - 是否显示当前生成页数状态
 * @param {string} [param.isTransformBaseY=false] - 是否将baseY按照比例缩小(一般固定A4页边距时候可以用上)
 * @returns {Promise} 根据outputType返回不同的数据类型,是一个对象,包含pdf结果及需要计算的元素位置信息
 */

export class PdfLoader {
    constructor(element, param = {}) {
        if (!(element instanceof HTMLElement)) {
            throw new TypeError('element节点请传入dom节点')
        }
        this.element = element
        this.contentWidth = param.contentWidth || 550
        this.outputType = param.outputType || 'save'
        this.fileName = param.fileName || '导出的pdf文件'
        this.scale = param.scale
        this.baseY = param.baseY == null ? 15 : param.baseY
        this.isTransformBaseY = param.isTransformBaseY || false
        this.header = param.header
        this.footer = param.footer
        this.isPageMessage = param.isPageMessage
        this.direction = param.direction || 'p' // 默认竖向,l横向
        this.A4_WIDTH = 595 // a4纸的尺寸[595,842],单位像素
        this.A4_HEIGHT = 842
        this.contentFooterWidth = param.contentHeaderWidth || 550
        this.contentHeaderWidth = param.contentFooterWidth || 550
        if (this.direction === 'l') {
            // 如果是横向,交换a4宽高参数
            ;[this.A4_HEIGHT, this.A4_WIDTH] = [this.A4_WIDTH, this.A4_HEIGHT]
        }
        // 页眉页脚高度
        this.pdfFooterHeight = 0
        this.pdfHeaderHeight = 0
        this.pdf = null
        this.rate = 1 // 缩放比率
        this.pages = [] // 当前分页数据
    }
    /**
     * 将元素转化为canvas元素
     * @param {HTMLElement} element - 当前要转换的元素
     * @param {width} width - 内容宽度
     * @returns
     */
    async createAndDisplayCanvas() {
        let imgData = await this.toCanvas(this.element, this.contentWidth)
        let canvasEle = document.createElement('canvas')
        canvasEle.width = imgData.width
        canvasEle.height = imgData.height
        canvasEle.style.position = 'fixed'
        canvasEle.style.top = '0'
        canvasEle.style.right = '0'
        this.canvasEle = canvasEle
        document.body.appendChild(canvasEle)
        const ctx = canvasEle.getContext('2d')
        const img = await this.loadImage(imgData.data)
        ctx.drawImage(img, 0, 0, imgData.width, imgData.height)
        this.scan(ctx, imgData)
    }

    /**
     * 加载图片资源
     * @param url 图片资源链接
     * @returns 返回Promise对象,当图片加载成功时resolve,否则reject
     */
    loadImage(url) {
        return new Promise((resolve, reject) => {
            const img = new Image()
            img.setAttribute('crossOrigin', 'anonymous')
            img.src = url
            img.onload = () => {
                // 当图像加载完成后进行resolve
                resolve(img)
            }
            img.onerror = () => {
                reject(new Error('图像加载失败'))
            }
        })
    }

    /**
     * 扫描图像并确定每一页的起始高度
     * @param ctx 绘制上下文
     * @param imgData 图像数据
     * @throws 当ctx或imgData为null/undefined时抛出错误
     */
    scan(ctx, imgData) {
        if (!ctx || !imgData) {
            throw new Error(
                'Invalid arguments: ctx or imgData is null/undefined'
            )
        }
        let originalPageHeight = parseInt(this.originalPageHeight, 10)
        let shouldContinueScanning = true
        while (shouldContinueScanning) {
            let imageData = ctx.getImageData(
                0,
                originalPageHeight,
                imgData.width,
                1
            )
            const uniqueArr = Array.from(new Set(imageData.data))
            if (uniqueArr.length === 1) {
                this.pages.push(originalPageHeight)
                originalPageHeight += parseInt(this.originalPageHeight, 10)
                if (originalPageHeight > imgData.height) {
                    shouldContinueScanning = false
                    if (this.canvasEle) {
                        this.canvasEle.remove()
                        this.canvasEle = null
                    }
                }
            } else {
                if (originalPageHeight == this.pages.at(-1)) {
                    // 防止无限递减
                    shouldContinueScanning = false
                } else {
                    originalPageHeight = Math.max(0, originalPageHeight - 1) // 防止originalPageHeight变为负数
                }
            }
        }
    }

    async toCanvas(element, width) {
        // canvas元素
        let canvas = await html2canvas(element, {
            allowTaint: true, // 允许渲染跨域图片
            scale: this.scale || window.devicePixelRatio * 2, // 增加清晰度
            useCORS: true, // 允许跨域
        })
        // 获取canvas转化后的宽度
        const canvasWidth = canvas.width
        // 获取canvas转化后的高度
        const canvasHeight = canvas.height
        // 高度转化为PDF的高度
        const height = (width / canvasWidth) * canvasHeight
        // 转化成图片Data
        const canvasData = canvas.toDataURL('image/jpeg', 1.0)
        canvas = null
        return { width, height, data: canvasData }
    }

    /**
     * 生成pdf方法,外面调用这个方法
     * @returns {Promise} 返回一个promise
     */
    getPdf() {
        // 滚动置顶,防止顶部空白
        window.pageYOffset = 0
        document.documentElement.scrollTop = 0
        document.body.scrollTop = 0
        return new Promise(async (resolve, reject) => {
            // jsPDF实例
            const pdf = new jsPDF({
                unit: 'pt', // mm,pt,in,cm
                format: 'a4',
                orientation: this.direction,
            })

            this.pdf = pdf
            let pdfFooterHeight = 0
            let pdfHeaderHeight = 0

            // 距离PDF左边的距离,/ 2 表示居中 ,,预留空间给左边,  右边,也就是左右页边距
            let baseX = (this.A4_WIDTH - this.contentWidth) / 2

            // 距离PDF 页眉和页脚的间距, 留白留空
            let baseY = this.baseY
            // 元素在网页页面的宽度
            const elementWidth = this.element.scrollWidth

            // PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度  转化为 距离Canvas顶部的高度
            const rate = this.contentWidth / elementWidth
            this.rate = rate
            if (this.isTransformBaseY) {
                this.baseY = baseY = baseY * rate
            }

            // 页脚元素 经过转换后在PDF页面的高度
            if (this.footer) {
                pdfFooterHeight = (
                    await this.toCanvas(this.footer, this.contentFooterWidth)
                ).height
                this.pdfFooterHeight = pdfFooterHeight
            }

            // 页眉元素 经过转换后在PDF的高度
            if (this.header) {
                pdfHeaderHeight = (
                    await this.toCanvas(this.header, this.contentHeaderWidth)
                ).height
                this.pdfHeaderHeight = pdfHeaderHeight
            }

            // 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
            const originalPageHeight =
                this.A4_HEIGHT - pdfFooterHeight - pdfHeaderHeight - 2 * baseY
            this.originalPageHeight = originalPageHeight
            this.pages = [0] // 要从0开始
            // 计算分页
            await this.createAndDisplayCanvas()
            const pages = this.pages
            const { width, height, data } = await this.toCanvas(
                this.element,
                this.contentWidth
            )
            // 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
            if (pages[pages.length - 1] + originalPageHeight < height) {
                pages.push(pages[pages.length - 1] + originalPageHeight)
            }

            // 根据分页位置 开始分页生成pdf
            for (let i = 0; i < pages.length; ++i) {
                if (this.isPageMessage) {
                    // Message.success(`共${pages.length}页, 生成第${i + 1}页`);
                }
                // 页眉高度
                let pdfHeaderH = pdfHeaderHeight
                // 页脚高度
                let pdfFooterH = pdfFooterHeight
                // 根据分页位置新增图片,要排除页眉和顶部留白
                this.addImage(
                    baseX,
                    baseY + pdfHeaderH - pages[i],
                    pdf,
                    data,
                    width,
                    height
                )

                // 将 内容 与 页眉之间留空留白的部分进行遮白处理
                this.addBlank(0, pdfHeaderH, this.A4_WIDTH, baseY, pdf)
                // 将 内容 与 页脚之间留空留白的部分进行遮白处理
                this.addBlank(
                    0,
                    this.A4_HEIGHT - baseY - pdfFooterH,
                    this.A4_WIDTH,
                    baseY,
                    pdf
                )
                // 对于除最后一页外,对 内容 的多余部分进行遮白处理
                if (i < pages.length - 1) {
                    // 获取当前页面需要的内容部分高度
                    const imageHeight = pages[i + 1] - pages[i]
                    // 对多余的内容部分进行遮白
                    this.addBlank(
                        0,
                        baseY + imageHeight + pdfHeaderH,
                        this.A4_WIDTH,
                        this.A4_HEIGHT - imageHeight,
                        pdf
                    )
                }
                // 添加页眉
                await this.addHeader(
                    i + 1,
                    this.header,
                    pdf,
                    this.contentHeaderWidth
                )
                // 添加页脚
                await this.addFooter(
                    pages.length,
                    i + 1,
                    this.footer,
                    pdf,
                    this.contentFooterWidth
                )

                // 若不是最后一页,则分页
                if (i !== pages.length - 1) {
                    // 增加分页
                    pdf.addPage()
                }
            }
            try {
                const result = await this.getPdfByType(pdf)
                resolve({
                    pdfResult: result,
                })
            } catch (error) {
                reject('生成pdf出错', error)
            }
        })
    }

    // 根据类型获取pdf
    getPdfByType(pdf) {
        let result = null
        switch (this.outputType) {
            case 'file':
                result = new File([pdf.output('blob')], this.fileName, {
                    type: 'application/pdf',
                    lastModified: Date.now(),
                })
                break
            case 'save':
                result = pdf.save(this.fileName)
                break
            default:
                result = pdf.output(this.outputType)
        }
        return result
    }

    /**
     * 添加页眉
     * @param {HTMLElement} header -页眉元素
     * @param {Object} pdf - pdf实例
     * @param {Number} contentWidth -在pdf中占据的宽度(默认占满)
     * @returns
     */
    async addHeader(pageNo, header, pdf, contentWidth) {
        if (!header || !(header instanceof HTMLElement)) {
            return
        }
        if (!this.__header) {
            // 其他页 页头都是一样的,不需要每次都生成
            this.__header = await this.toCanvas(header, contentWidth)
        }
        //  每页都从 0 0 开始?
        const { height, data } = this.__header
        let leftX = (this.A4_WIDTH - this.contentHeaderWidth) / 2
        pdf.addImage(data, 'JPEG', leftX, 0, contentWidth, height)
    }

    /**
     * 添加页脚
     * @param {Number} pageSize -总页数
     * @param {Number} pageNo -当前第几页
     * @param {HTMLElement} footer -页脚元素
     * @param {Object} pdf - pdf实例
     * @param {Number} contentWidth - 在pdf中占据的宽度(默认占满)
     * @returns
     */
    async addFooter(pageSize, pageNo, footer, pdf, contentWidth) {
        if (!footer || !(footer instanceof HTMLElement)) {
            return
        }

        // 页码元素,类名这里写死了
        let pageNoDom = footer.querySelector('.pdf-footer-page')
        let pageSizeDom = footer.querySelector('.pdf-footer-page-count')
        if (pageNoDom) {
            pageNoDom.innerText = pageNo
        }
        if (pageSizeDom) {
            pageSizeDom.innerText = pageSize
        }

        // 如果设置了页码的才需要每次重新生成cavan
        if (pageNoDom || !this.__footer) {
            this.__footer = await this.toCanvas(footer, contentWidth)
        }
        let leftX = (this.A4_WIDTH - this.contentFooterWidth) / 2
        const { height, data } = this.__footer
        // 高度位置计算:当前a4高度 - 页脚在pdf中的高度
        pdf.addImage(
            data,
            'JPEG',
            leftX,
            this.A4_HEIGHT - height,
            contentWidth,
            height
        )
    }

    // 截取图片
    addImage(_x, _y, pdf, data, width, height) {
        pdf.addImage(data, 'JPEG', _x, _y, width, height)
    }

    /**
     * 添加空白遮挡
     * @param {Number} x - x 与页面左边缘的坐标(以 PDF 文档开始时声明的单位)
     * @param {Number} y - y 与页面上边缘的坐标(以 PDF 文档开始时声明的单位)
     * @param {Number} width - 填充宽度
     * @param {Number} height -填充高度
     * @param {Object} pdf - pdf实例
     * @returns
     */
    addBlank(x, y, width, height, pdf) {
        pdf.setFillColor(255, 255, 255)
        // rect(x, y, w, h, style) ->'F'填充方式,默认是描边方式
        pdf.rect(x, y, Math.ceil(width), Math.ceil(height), 'F')
    }
}

调用示例:

javascript 复制代码
import { PdfLoader } from '@/utils/downPdf'
//点击下载
const handleDownload = () => {
    // 获取要转换的DOM元素
    const element = document.getElementById('pdf-container')

    // 创建PDF加载器实例
    const pdfLoader = new PdfLoader(element, {
        contentWidth: 550,
        fileName: '技术报告.pdf',
        baseY: 15,
        // header: document.getElementById('pdf-header'),
        // footer: document.getElementById('pdf-footer'),
        isPageMessage: true,
        direction: 'p', // 竖向
        scale: window.devicePixelRatio * 2,
    })

    // 生成PDF
    pdfLoader
        .getPdf()
        .then((result) => {
            console.log('PDF生成成功', result)
        })
        .catch((error) => {
            console.error('PDF生成失败', error)
        })
}