vue 使用 docxtemplater 导出 word 文件

BACK 2023-12-05 12-30-00
  • docxtemplater
  • 文件导出

docxtemplater 导出 word

docxtemplater 是一种邮件合并工具,以编程方式使用并处理条件、循环,并且可以扩展以插入任何内容(表格、html、图像)

一、安装 docxtemplater

// 安装 docxtemplater
npm install docxtemplater pizzip  --save
// 安装 jszip-utils
npm install jszip-utils --save
// 安装 FileSaver
npm install file-saver --save
// 引入处理图片的插件
npm install docxtemplater-image-module-free --save
//docx-preview预览插件
npm i docx-preview --save

二、docxtemplater 语法

1. 单一变量使用 {} 包含

{
    name: 'lisi',
    age: 18
}

在 word 模板文件中表示为:

{name}、{age}

2. json数组格式,则包裹一个循环对象:

list: [
    {name:'lisi', class: '4-1', age:'18'},
    {name:'wangwu', class: '4-2', age:'16'}
]

在 word 模板文件中表示为:

姓名班级年龄
内容默认居左内容居中内容居右
{#list}{name}{class}{age}{/list}

3. 使用图片地址:

{%imgUrl}

4. 如果多张图片地址时,与循环列表一样, 但必须换行

{#imglist}
    {%imgUrl}
{/imglist} 

三、封装JS

新建 outputWord.js 文件

/**
 * 导出word文档
 * 
// 安装 docxtemplater
npm install docxtemplater pizzip  --save
// 安装 jszip-utils
npm install jszip-utils --save
// 安装 FileSaver
npm install file-saver --save
// 引入处理图片的插件
npm install docxtemplater-image-module-free --save
//docx-preview预览插件
npm i docx-preview --save

// 安装全部
npm install docxtemplater pizzip jszip-utils file-saver docxtemplater-image-module-free docx-preview --save
 */
import Docxtemplater from 'docxtemplater'
import ImageModule from 'docxtemplater-image-module-free'
import PizZip from 'pizzip'
import JSZipUtils from 'jszip-utils'
import { renderAsync } from 'docx-preview'
import { saveAs } from 'file-saver'
import * as echarts from 'echarts'

let imageMaxWidth = 640
let createdTime = ''
let imageSizeObjs = {}
/**
 * 将base64格式的数据转为ArrayBuffer
 * @param {Object} dataURL base64格式的数据
 */
function base64DataURLToArrayBuffer(dataURL) {
    const base64Regex = /^data:image\/(png|jpg|jpeg|svg|svg\+xml);base64,/;
    if (!base64Regex.test(dataURL)) {
        return false
    }
    const stringBase64 = dataURL.replace(base64Regex, "")
    let binaryString
    if (typeof window !== "undefined") {
        binaryString = window.atob(stringBase64)
    } else {
        binaryString = Buffer.from(stringBase64, "base64").toString("binary")
    }
    const len = binaryString.length
    const bytes = new Uint8Array(len)
    for (let i = 0; i < len; i++) {
        const ascii = binaryString.charCodeAt(i)
        bytes[i] = ascii
    }
    return bytes.buffer
}
 
/**
 * 将图片的url路径转为base64路径
 * 可以用await等待Promise的异步返回
 * @param {Object} imgUrl 图片路径
 */
export const getBase64Sync = (imgUrl) => {
    return new Promise((resolve, reject) => {
        // 一定要设置为let,不然图片不显示
        let image = new Image()
        //图片地址
        image.src = imgUrl
        // 解决跨域问题
        image.setAttribute("crossOrigin", '*')  // 支持跨域图片
        // image.onload为异步加载
        image.onload = () => {
            let canvas = document.createElement("canvas")
            const {width, height} = image
            canvas.width = width
            canvas.height = height
            let context = canvas.getContext("2d")
            context.drawImage(image, 0, 0, width, height)
            //图片后缀名
            let ext = image.src.substring(image.src.lastIndexOf(".") + 1).toLowerCase()
            //图片质量
            let quality = 0.8
            //转成base64
            let dataurl = canvas.toDataURL("image/" + ext, quality)
            // 将缩放后的尺寸信息保存
            let key = fomratKey(dataurl)
            imageSizeObjs[key] = imageScaleSize(width, height)
            // 解除引用
            image = null
            //返回
            resolve(dataurl)
        };
    })
}
// 截取base64后25位,加key,作为imageSizeObjs的key值
const fomratKey = (base64Data) => {
    const last25 = base64Data.substring(base64Data.length - 25)
    return `${last25}`
}

// 缩放图片尺寸
const imageScaleSize = (w, h) => {
    if (w > imageMaxWidth) {
        const scale = imageMaxWidth / w
        w *= scale
        h *= scale
        return([Math.ceil(w), Math.ceil(h)])
    } else {
        return([w, h])
    }
}
 
/**
 * 将echart转为base64
 * 可以用await等待Promise的异步返回
 * @param {Object} imgUrl 图片路径
 */
export const chartToBase64Pic = (opt) => {
    const {dom, bgColor = '#fff'} = opt || {}
    return new Promise((resolve, reject) => {
        if (!dom) {
            console.warn('未传入echarts的dom')
            resolve(null)
        }
        const echartDom = echarts.getInstanceByDom(dom)
        const base64Str = echartDom.getDataURL({
            type: 'png',
            //导出的图片分辨率比例,默认为 1
            pixelRatio: 1,
            // 导出的图片背景色,默认使用 option 里的 backgroundColor
            backgroundColor: bgColor
        })
        // 将缩放后的尺寸信息保存
        let key = fomratKey(base64Str)
        imageSizeObjs[key] = imageScaleSize(dom.offsetWidth, dom.offsetHeight)
        resolve(base64Str)
    })
}

// 获取年月日时分秒
const getDate = () => {
    const now = new Date()
    const year = now.getFullYear()
    const month = (now.getMonth() + 1).toString().padStart(2, '0')
    const day = now.getDate().toString().padStart(2, '0')
    const hour = now.getHours().toString().padStart(2, '0')
    const minute = now.getMinutes().toString().padStart(2, '0')
    const second = now.getSeconds().toString().padStart(2, '0')
    return [
        `${year}-${month}-${day} ${hour}:${minute}:${second}`,
        `${year}年${month}月${day}日${hour}时${minute}分${second}秒`,
        `${year}-${month}-${day}`,
        `${year}年${month}月${day}日`
    ]
}

/** 
 * 递归所有数据,并对图片处理,返回新的对象
 * 避免污染原数据
 */
const deepCopyAndHandleBase64 = async (obj) => {
    if (obj === null || typeof obj !== 'object') {
        return obj
    }
    let copy = Array.isArray(obj)? [] : {}
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            const val = obj[key]
            if (val instanceof Element) {
                // 将 echart dom 的对象转成base64
                copy[key] = await chartToBase64Pic({dom: val})
            } else if (typeof(val) === 'string' && val.indexOf('http') !== -1) {
                // 将http的图片转成base64
                copy[key] = await getBase64Sync(val)
            } else {
                copy[key] = await deepCopyAndHandleBase64(obj[key])
            }
        }
    }
    return copy
}

/**
 * 渲染文档
 * @param {Object} outputWordData 必填,要导出的数据列表
 * @param {Object} fileConfigInfo 选填,配置文件,内容包括
 *     @param {String} templateDocxPath 必填,模板路径
 *     @param {String} imgMaxWidth 选填,图片的最大宽度,超过按比例缩放
 *     @param {String} placeInfo 选填,页面的信息,会混入 outputWordData
 */
export const ExportBriefDataDocx = async (outputWordData, fileConfigInfo) => {
    imageSizeObjs = {}

    const {imgMaxWidth, placeInfo = {}, templateDocxPath} = fileConfigInfo || {}
    // 如果有传入 imgMaxWidth 
    if (imgMaxWidth) imageMaxWidth = imgMaxWidth
    let outData = await deepCopyAndHandleBase64(outputWordData)
    outData = Object.assign(outData, placeInfo)

    // 如果没有createdTime的对象,就创建当前时间
    const [fullDate, fullDateCn, date, dateCn] = getDate()
    if (!outData.createdTime) {
        outData.createdTime = fullDate
        createdTime = fullDate
    } else {
        createdTime = outData.createdTime
    }
    outData.fullDateCn = fullDateCn
    outData.dateCn = dateCn
    return new Promise((resolve) => {
        //这里要引入处理图片的插件
        JSZipUtils.getBinaryContent(templateDocxPath, (error, content) => {
            if (error) {
                console.log(error)
            }
        
            let imageOpts = {
                //图像是否居中
                centered: true,
                //将base64的数据转为ArrayBuffer
                getImage: (chartId) => {
                    return base64DataURLToArrayBuffer(chartId)
                },
                getSize: (img, tagValue, tagName) => {
                    const key = fomratKey(tagValue)
                    return imageSizeObjs[key] || []
                }
            }

            // 创建一个JSZip实例,内容为模板的内容        
            const zip = new PizZip(content)
            // 创建并加载 Docxtemplater 实例对象
            // 设置模板变量的值
            let doc = new Docxtemplater()
            doc.attachModule(new ImageModule(imageOpts))
            doc.loadZip(zip)
            doc.setData(outData)
            try {
                // 呈现文档,会将内部所有变量替换成值,
                doc.render()
            } catch (error) {
                const e = {
                    message: error.message,
                    name: error.name,
                    stack: error.stack,
                    properties: error.properties
                }
                console.log('err',{ error: e })
                // 当使用json记录时,此处抛出错误信息
                throw error
            }
            // 生成一个代表Docxtemplater对象的zip文件(不是一个真实的文件,而是在内存中的表示)
            const out = doc.getZip().generate({
                type: 'blob',
                mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
            })
            resolve(out)
        })
    })
}
export const ViewWordFile = (out, dom) => {
    dom
        ? renderAsync(out, dom)
        : console.warn('dom 未定义')
}

export const DownWordFile = (out, outFileName) => {
    outFileName
        ? saveAs(out, `${outFileName}(${createdTime})`)
        : console.warn('outFileName 未定义')
} 

四、封装vue组件

新建 outputWord.vue 文件

  1. 例子使用 elementUI,可以删除相关代码
  2. 例子是支持 echart 图形导出,需要引入 echart
/*
    Component: outputWord
*/
<template>
    <div class="outputWord" style="display:inline-block;">
        <el-button :size="size" type="success" icon="el-icon-download" @click="exportWordFile" >
            {{title}}
        </el-button>
        <el-dialog
            fullscreen
            append-to-body
            title="生成智能报告"
            :visible.sync="dialogVisible">
            <div ref="dailyContent" style="height:calc(100vh - 155px); overflow: auto;"></div>
            <span slot="footer" class="dialog-footer">
                <el-button type="success" icon="el-icon-download" @click="download">导出</el-button>
                <el-button @click="dialogVisible = false">关闭</el-button>
            </span>
        </el-dialog>
    </div>
</template>

<script>
import {
    ExportBriefDataDocx,
    ViewWordFile,
    DownWordFile
} from '@/utils/methods/outputWord/outputWord.js'


export default {
    props: {
        title: {
            default: '导出预览'
        },
        templateName: {
            default: ''
        },
        outputWordData: {
            type: Object,
            default: () => null
        },
        outFileName: {
            type: String,
            default: '未定义'
        },
        size: {
            type: String,
            default: ''
        }
    },
    data () {
        return {
            docx: null,
            dialogVisible: false,
            fileConfig: {
                agjlxtj: {
                    outFileName: "测试word文档",
                    templateDocxPath: "/测试word文档.docx",
                    imgMaxWidth: 750,
                    placeInfo: {
                        headerTitle: "信息科技有限公司",
                        headerSubTitle: "信息科技有限公司"
                    }
                }
            }
        }   
    },
    mounted () {
    },
    methods:{
        async exportWordFile (){
            const fileConfigInfo = this.fileConfig[this.templateName]
            this.docx = null
            this.dialogVisible = true
            this.docx = await ExportBriefDataDocx(this.outputWordData, fileConfigInfo)
            ViewWordFile(this.docx, this.$refs.dailyContent)
        },
        download () {
            DownWordFile(this.docx, this.outFileName)
        }
    }
}
</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
>>> .el-dialog .el-dialog__body
    padding 0
</style>

五、调用方法

新建 index.vue 文件

/*
    Component: index
*/
<template>
    <div class="index">
        <outputWord
            title="生成报表"
            outFileName="测试统计报表"
            templateName="agjlxtj"
            :outputWordData="outputWordData"
        />
        <el-button style="margin-left: 10px;" @click="getDataList">生成数据</el-button>
        <el-table :data="outputWordData.list" border style="width: 100%">
            <el-table-column prop="name" label="名称" />
            <el-table-column prop="value" label="数量" />
        </el-table>
        <div ref="pieChart" style="width: 500px; height: 500px;"></div>
    </div>
</template>

<script>
import * as echarts from 'echarts'
import outputWord from "@/utils/methods/outputWord/outputWord.vue"
export default {
    name: "index",
    components: {
        outputWord
    },
    props: {},
    data () {
        return {
            outputWordData: {
                title: '测试word文档',
                subtitle1: '统计列表',
                subtitle2: '占比统计',
                list: [],
                imglist:[
                    {
                        imgUrl: "https://t7.baidu.com/it/u=4198287529,2774471735&fm=193&f=GIF"
                    },
                    {
                        imgUrl: "https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
                    }
                ],
                echartBase64: ''
            }
        }
    },
    mounted() {
        this.pieChartRef = echarts.init(this.$refs.pieChart)
        // 将要导出图形的dom赋值
        this.outputWordData.echartBase64 = this.$refs.pieChart
        this.getDataList()
    },
    methods: {
        random (limit = 1001) {
            return Math.floor(Math.random() * limit)
        },
        getDataList () {
            const length = this.random(10) + 5
            const list = []
            for(let i = 0; i < length; i++) {
                const index = i + 1
                list.push({
                    index, name: `person-${index}`, value: this.random()
                })
            }
            this.outputWordData.list = list
        this.drawPicChart(list)
        },
        drawPicChart (data) {
            const option = {
                tooltip: {
                    trigger: 'item'
                },
                legend: {
                    top: '5%',
                    left: 'center'
                },
                series: [
                    {
                        name: 'Access From',
                        type: 'pie',
                        radius: ['40%', '70%'],
                        avoidLabelOverlap: false,
                        itemStyle: {
                            borderRadius: 10,
                            borderColor: '#fff',
                            borderWidth: 2
                        },
                        label: {
                            show: false,
                            position: 'center'
                        },
                        labelLine: {
                            show: false
                        },
                        data
                    }
                ]
            }
            this.pieChartRef.setOption(option)
        }
    }
}

</script>
<style scoped lang="stylus" rel="stylesheet/stylus">
.index
    padding 10px
</style>
Copyright © 2016-2024 Nuxt - MIT License