Browse Source

feat: export word

chenlei 3 months ago
parent
commit
b1a2326588

+ 7 - 0
package.json

@@ -17,9 +17,15 @@
     "braft-editor": "^2.3.9",
     "braft-utils": "^3.0.12",
     "dayjs": "^1.11.10",
+    "docxtemplater": "^3.61.1",
+    "docxtemplater-image-module-free": "^1.1.1",
     "echarts": "^5.6.0",
+    "file-saver": "^2.0.5",
     "js-base64": "^3.7.3",
     "js-export-excel": "^1.1.4",
+    "jszip-utils": "^0.1.0",
+    "lodash": "^4.17.21",
+    "pizzip": "^3.1.8",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-redux": "^8.0.4",
@@ -59,6 +65,7 @@
   },
   "devDependencies": {
     "@types/history": "^5.0.0",
+    "@types/lodash": "^4.17.16",
     "@types/react-router-dom": "^5.3.3",
     "customize-cra": "^1.0.0",
     "react-app-rewired": "^2.2.1"

BIN
public/templates/1.docx


BIN
public/templates/2.docx


BIN
public/templates/3.docx


BIN
public/templates/4.docx


BIN
public/templates/5.docx


BIN
public/templates/6.docx


BIN
public/templates/7.docx


+ 1 - 1
src/pages/B_enterTibet/B3_4page/B3edit/main.tsx

@@ -279,7 +279,7 @@ function B3editMain({ pageSta }: Props) {
           </Button>
         ) : null}
 
-        {EXbtnFu()}
+        {EXbtnFu(topInfo)}
 
         {btnFlagFu2(topInfo)['删除'] ? (
           <MyPopconfirm

+ 1 - 1
src/pages/D_storeManage/D4impStor/D4edit/index.tsx

@@ -425,7 +425,7 @@ function D4edit() {
           </Button>
         ) : null}
 
-        {EXbtnFu()}
+        {EXbtnFu(topInfo)}
 
         {btnFlagFu2(topInfo)['删除'] ? (
           <MyPopconfirm

+ 1 - 1
src/pages/D_storeManage/D6putsStor/D6edit/index.tsx

@@ -386,7 +386,7 @@ function D6edit() {
           </Button>
         ) : null}
 
-        {EXbtnFu()}
+        {EXbtnFu(topInfo)}
 
         {btnFlagFu2(topInfo)['删除'] ? (
           <MyPopconfirm

+ 19 - 1
src/pages/Y_goodsDetails/Y2look/index.tsx

@@ -12,6 +12,8 @@ import Y33com from '../Y1cathet/Y33com'
 import Y44com from '../Y1cathet/Y44com'
 import { C1GoodType } from '@/pages/C_goodsManage/C1ledger/type'
 import { API_goodsInfo } from '@/store/action/C1ledger'
+import { EXPORT_WORD_ENUM, exportWordHandler } from '@/utils/exportWordTemplates'
+import { API_goodFileList } from '@/store/action/C2files'
 
 function Y2look() {
   const { id: sId } = useParams<any>()
@@ -25,6 +27,19 @@ function Y2look() {
     }
   }, [])
 
+  const handleExport = async () => {
+    const res = await API_goodFileList(sId)
+    exportWordHandler(EXPORT_WORD_ENUM.COLLECTION_CARD, {
+      ...info,
+      imagePages: [
+        {
+          thumb: info.thumb
+        },
+        ...res.data.filter((i: any) => i.type === 'img')
+      ]
+    })
+  }
+
   useEffect(() => {
     getInfoFu(sId)
   }, [getInfoFu, sId])
@@ -113,7 +128,10 @@ function Y2look() {
         </div>
         <div className='Y2toprr'>
           <Button type='primary'>关注</Button>&emsp;
-          <Button type='primary'>藏品卡片</Button>&emsp;
+          <Button type='primary' onClick={handleExport}>
+            藏品卡片
+          </Button>
+          &emsp;
           <Dropdown menu={{ items: items1 }} placement='bottom' arrow>
             <Button type='primary'>档案管理</Button>
           </Dropdown>

+ 14 - 8
src/utils/EXBtn.tsx

@@ -1,39 +1,45 @@
 import { Button, Dropdown, MenuProps } from 'antd'
+import { EXPORT_WORD_ENUM, exportWordHandler } from './exportWordTemplates'
 
 // 待完善
 
 const items: MenuProps['items'] = [
   {
-    key: '1',
+    key: EXPORT_WORD_ENUM.BORROW,
     label: <span className='Y2xia'>借用藏品点交凭证文件</span>
   },
   {
-    key: '2',
+    key: EXPORT_WORD_ENUM.HALL_PUT_BACK,
     label: <span className='Y2xia'>藏品馆内提退凭单</span>
   },
   {
-    key: '3',
+    key: EXPORT_WORD_ENUM.SUB_PUT_BACK,
     label: <span className='Y2xia'>分库藏品提退出入库记录单</span>
   },
   {
-    key: '4',
+    key: EXPORT_WORD_ENUM.SUB_PUT_IN,
     label: <span className='Y2xia'>分库藏品入库记录单</span>
   },
   {
-    key: '5',
+    key: EXPORT_WORD_ENUM.VOUCHER,
     label: <span className='Y2xia'>入馆凭证</span>
   },
   {
-    key: '6',
+    key: EXPORT_WORD_ENUM.FORM_FOR_DIGITAL,
     label: <span className='Y2xia'>藏品图片及相关数字化信息使用申请单</span>
   }
 ]
 
 // 查看详情的导出页面
 
-export const EXbtnFu = () => {
+export const EXbtnFu = (data?: any) => {
+  const handleExport: MenuProps['onClick'] = async ({ key }) => {
+    const k = Number(key) as EXPORT_WORD_ENUM
+    exportWordHandler(k, data)
+  }
+
   return (
-    <Dropdown menu={{ items }} placement='top' arrow>
+    <Dropdown menu={{ items, onClick: handleExport }} placement='top' arrow>
       <Button type='primary'>导出</Button>
     </Dropdown>
   )

+ 122 - 0
src/utils/exportWord.ts

@@ -0,0 +1,122 @@
+// @ts-ignore
+import JSZipUtils from 'jszip-utils'
+import docxtemplater from 'docxtemplater'
+// @ts-ignore
+import { saveAs } from 'file-saver'
+import PizZip from 'pizzip'
+
+export const getBase64Sync = (imgUrl: string) => {
+  return new Promise<string>(function (resolve, reject) {
+    // 一定要设置为let,不然图片不显示
+    let image = new Image()
+    image.crossOrigin = 'anonymous'
+    image.src = imgUrl
+    image.onload = function () {
+      let canvas = document.createElement('canvas')
+      canvas.width = image.width
+      canvas.height = image.height
+      let context = canvas.getContext('2d')
+      context?.drawImage(image, 0, 0, image.width, image.height)
+      //图片后缀名
+      let ext = image.src.substring(image.src.lastIndexOf('.') + 1).toLowerCase()
+      let quality = 0.8
+      let dataurl = canvas.toDataURL('image/' + ext, quality)
+      resolve(dataurl)
+    }
+    image.onerror = function (err) {
+      reject(err)
+    }
+  })
+}
+
+export const base64DataURLToArrayBuffer = (dataURL: string) => {
+  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
+}
+
+export const exportWordDocx = async (
+  url: string,
+  docxData: Record<string, any>,
+  fileName: string
+) => {
+  const ImageModule = require('docxtemplater-image-module-free')
+
+  JSZipUtils.getBinaryContent(url, async function (error: unknown, content: any) {
+    if (error) {
+      throw error
+    }
+
+    // 创建一个PizZip实例,内容为模板的内容
+    let zip = new PizZip(content)
+    // 创建并加载docxtemplater实例对象
+    let doc = new docxtemplater().loadZip(zip)
+    doc.attachModule(
+      new ImageModule({
+        getImage: (base64: string) => base64DataURLToArrayBuffer(base64),
+        getSize: (buffer: string, base64: string, key: string) => {
+          if (docxData.imagePages) {
+            for (const row of docxData.imagePages) {
+              for (const col of row) {
+                for (const img of col) {
+                  if (img.img === base64) {
+                    return [img.width, img.height]
+                  }
+                }
+              }
+            }
+          }
+          return [70, 70]
+        }
+      })
+    )
+    // 去除未定义值所显示的undefined
+    doc.setOptions({
+      nullGetter: function () {
+        return ''
+      }
+    }) // 设置角度解析器Could not find a declaration file for module 'jszip-utils'.
+
+    doc.setData({
+      ...docxData
+    })
+
+    try {
+      // 用模板变量的值替换所有模板变量
+      doc.render()
+    } catch (error: any) {
+      let e = {
+        message: error.message,
+        name: error.name,
+        stack: error.stack,
+        properties: error.properties
+      }
+      console.log(JSON.stringify({ error: e }))
+      throw error
+    }
+
+    // 生成一个代表docxtemplater对象的zip文件(不是一个真实的文件,而是在内存中的表示)
+    let out = doc.getZip().generate({
+      type: 'blob',
+      mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+    })
+
+    // 将目标文件对象保存为目标类型的文件,并命名
+    saveAs(out, fileName)
+  })
+}

+ 390 - 0
src/utils/exportWordTemplates.ts

@@ -0,0 +1,390 @@
+import { cloneDeep } from 'lodash'
+import { exportWordDocx, getBase64Sync } from './exportWord'
+import { baseURL } from './http'
+import { resJiLianFu } from './history'
+
+export enum EXPORT_WORD_ENUM {
+  /** 借用藏品点交凭证 */
+  BORROW = 1,
+  /** 藏品馆内提退凭单 */
+  HALL_PUT_BACK = 2,
+  /** 分库藏品提退出入库记录单 */
+  SUB_PUT_BACK = 3,
+  /** 分库藏品入库记录单 */
+  SUB_PUT_IN = 4,
+  /** 入馆凭证 */
+  VOUCHER = 5,
+  /** 藏品图片及相关数字化信息使用申请单 */
+  FORM_FOR_DIGITAL = 6,
+  /** 藏品卡片 */
+  COLLECTION_CARD = 7
+}
+
+type WallItem = {
+  url: string
+  width: number
+  height: number
+  img: string
+}
+
+const WORD_FILE_NAME_MAP = {
+  [EXPORT_WORD_ENUM.BORROW]: {
+    fileName: '义乌市博物馆借用藏品点交凭证',
+    templateName: '1.docx'
+  },
+  [EXPORT_WORD_ENUM.HALL_PUT_BACK]: {
+    fileName: '义乌市博物馆藏品馆内提退凭单',
+    templateName: '2.docx',
+    // 每个字段单行最大字符数
+    perLine: {
+      num: 9,
+      name: 24,
+      pcsUnit: 4,
+      rtf: 6
+    },
+    row: {
+      // 首页最大行数
+      maxRowFirstPage: 1,
+      // 尾页最大行数
+      maxRowLastPage: 1,
+      // 每页最大行数
+      maxRowPage: 1,
+      // 首页单行最大字符行数
+      maxFirstCharLine: 20,
+      // 尾页单行最大字符行数
+      maxLastCharLine: 18,
+      // 每页最大字符行数
+      maxCharLine: 26,
+      // 表格每行最小字符行数
+      minRowCharLine: 2
+    }
+  },
+  [EXPORT_WORD_ENUM.SUB_PUT_BACK]: {
+    fileName: '义乌市博物馆分库藏品提退出入库记录单',
+    templateName: '3.docx'
+  },
+  [EXPORT_WORD_ENUM.SUB_PUT_IN]: {
+    fileName: '义乌市博物馆分库藏品入库记录单',
+    templateName: '4.docx'
+  },
+  [EXPORT_WORD_ENUM.VOUCHER]: {
+    fileName: '义乌市博物馆入馆凭证',
+    templateName: '5.docx'
+  },
+  [EXPORT_WORD_ENUM.FORM_FOR_DIGITAL]: {
+    fileName: '义乌市博物馆藏品图片及相关数字化信息使用申请单',
+    templateName: '6.docx'
+  },
+  [EXPORT_WORD_ENUM.COLLECTION_CARD]: {
+    fileName: '义乌市博物馆藏品卡片',
+    templateName: '7.docx'
+  }
+}
+
+export const numberToChinese = (num: number) => {
+  if (isNaN(num)) {
+    throw new Error('输入必须是一个有效的数字')
+  }
+  if (num < 0 || num > 9999) {
+    throw new Error('输入数字超出范围 (0-9999)')
+  }
+  if (num === 0) {
+    return '零'
+  }
+
+  const chineseNumbers = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
+  const chineseUnits = ['', '拾', '佰', '仟']
+
+  let result = ''
+  const numStr = num.toString()
+  const length = numStr.length
+
+  for (let i = 0; i < length; i++) {
+    const digit = parseInt(numStr[i])
+    const unit = length - i - 1
+
+    if (digit !== 0) {
+      result += chineseNumbers[digit] + chineseUnits[unit]
+    } else {
+      // 处理连续的零,只保留一个
+      if (i < length - 1 && numStr[i + 1] !== '0') {
+        result += chineseNumbers[digit]
+      }
+    }
+  }
+
+  // 处理10-19的情况,去掉开头的"壹"
+  if (num >= 10 && num < 20) {
+    result = result.replace('壹拾', '拾')
+  }
+
+  return result
+}
+
+const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
+  return new Promise(resolve => {
+    const img = new Image()
+    img.onload = () => {
+      resolve({ width: img.width, height: img.height })
+    }
+    img.onerror = () => {
+      resolve({ width: 100, height: 100 })
+    }
+    img.src = url
+  })
+}
+
+/**
+ * 将一维数组根据图片宽高比转成三维数组
+ */
+const arrangeImages = async (images: { thumb: string }[]) => {
+  const MAX_WALL_WIDTH = 520
+  const MAX_WALL_HEIGHT = 750
+  const MAX_ROWS_PER_WALL = 3
+
+  const walls: Array<Array<Array<WallItem>>> = []
+
+  let currentWall: Array<Array<WallItem>> = []
+  let currentRow: Array<WallItem> = []
+
+  let currentRowHeight = 0
+  let currentX = 0
+
+  const imagesWithDimensions = await Promise.all(
+    images.map(async img => {
+      const url = baseURL + img.thumb
+      const dimensions = await getImageDimensions(url)
+      return {
+        url,
+        originalWidth: dimensions.width,
+        originalHeight: dimensions.height
+      }
+    })
+  )
+
+  for (const img of imagesWithDimensions) {
+    const maxRowHeight = MAX_WALL_HEIGHT / MAX_ROWS_PER_WALL
+    const aspectRatio = img.originalWidth / img.originalHeight
+
+    let scaledWidth, scaledHeight
+
+    scaledHeight = maxRowHeight
+    scaledWidth = maxRowHeight * aspectRatio
+
+    if (scaledWidth > MAX_WALL_WIDTH) {
+      scaledWidth = MAX_WALL_WIDTH
+      scaledHeight = MAX_WALL_WIDTH / aspectRatio
+    }
+
+    // TOFIX: 暂时无法解决图片并列渲染问题
+    if (currentX + scaledWidth <= MAX_WALL_WIDTH && false) {
+      currentRow.push({
+        url: img.url,
+        width: scaledWidth,
+        height: scaledHeight,
+        img: await getBase64Sync(img.url)
+      })
+      currentX += scaledWidth
+      currentRowHeight = Math.max(currentRowHeight, scaledHeight)
+    } else {
+      if (currentRow.length > 0) {
+        // eslint-disable-next-line no-loop-func
+        currentRow.forEach(item => {
+          item.height = currentRowHeight
+        })
+        currentWall.push(currentRow)
+      }
+
+      if (currentWall.length >= MAX_ROWS_PER_WALL) {
+        walls.push(currentWall)
+        currentWall = []
+      }
+
+      currentRow = [
+        {
+          url: img.url,
+          width: scaledWidth,
+          height: scaledHeight,
+          img: await getBase64Sync(img.url)
+        }
+      ]
+      currentX = scaledWidth
+      currentRowHeight = scaledHeight
+    }
+  }
+
+  if (currentRow.length > 0) {
+    currentWall.push(currentRow)
+  }
+  if (currentWall.length > 0) {
+    walls.push(currentWall)
+  }
+
+  return walls
+}
+
+const getEffectiveLength = (str: string) => {
+  let length = 0
+  for (const char of str) {
+    // 全角字符(包括中文、全角符号等)的 Unicode 范围判断
+    if (char.match(/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/)) {
+      length += 2 // 全角算2字符
+    } else {
+      length += 1 // 半角算1字符
+    }
+  }
+  return length
+}
+
+const calculateRowCharLines = (row: Record<string, string>, perLine: Record<string, number>) => {
+  let maxCharLines = 0
+  for (const [field, text] of Object.entries(row)) {
+    if (!perLine[field]) continue
+    const fieldLength = getEffectiveLength(text)
+    const fieldCharLines = Math.ceil(fieldLength / perLine[field])
+    maxCharLines = Math.max(maxCharLines, fieldCharLines)
+  }
+  return maxCharLines || 1
+}
+
+/**
+ * 计算表格数据的分页情况
+ */
+const calcTablePages = (rows: any[], config: (typeof WORD_FILE_NAME_MAP)[2]) => {
+  if (rows.length === 0) return 0
+
+  let totalPages = 0
+  let currentPageRows: any[] = []
+  let currentPageCharLines = 0
+  let isFirstPage = true
+  let isLastPage = false
+
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i]
+    const rowCharLines = calculateRowCharLines(row, config.perLine)
+
+    currentPageCharLines += rowCharLines
+    currentPageRows.push(row)
+
+    // 判断是否超出首页
+    if (isFirstPage && currentPageCharLines > config.row.maxFirstCharLine) {
+      isFirstPage = false
+      totalPages++
+      currentPageCharLines -= config.row.maxFirstCharLine
+
+      // 如果剩下行数还超出尾页最大行数,也先预处理
+      if (currentPageCharLines > config.row.maxLastCharLine) {
+        totalPages++
+        currentPageCharLines -= config.row.maxLastCharLine
+      }
+    }
+
+    // 确定当前页的限制
+    const maxRows = isFirstPage
+      ? config.row.maxRowFirstPage
+      : isLastPage
+      ? config.row.maxRowLastPage
+      : config.row.maxRowPage
+
+    const maxCharLines = isFirstPage
+      ? config.row.maxFirstCharLine
+      : isLastPage
+      ? config.row.maxLastCharLine
+      : config.row.maxCharLine
+
+    // 检查是否换页
+    const isPageFull = currentPageRows.length >= maxRows || currentPageCharLines >= maxCharLines
+
+    if (isPageFull || i === rows.length - 1) {
+      totalPages +=
+        Math.floor(currentPageCharLines / maxCharLines) +
+        (currentPageCharLines % maxCharLines > 0 ? 1 : 0)
+      currentPageRows = []
+      currentPageCharLines = 0
+      isFirstPage = false
+      isLastPage = i === rows.length - 1
+    }
+  }
+
+  return totalPages
+}
+
+/**
+ * 根据业务类型导出数据
+ */
+export const exportWordHandler = async (type: EXPORT_WORD_ENUM, data: Record<any, any>) => {
+  let temp = cloneDeep(data)
+  let page = 1
+  const date = new Date()
+  const item = WORD_FILE_NAME_MAP[type]
+
+  temp.sizeUnit && (temp.sizeUnit = resJiLianFu(temp.sizeUnit))
+  temp.qualityUnit && (temp.qualityUnit = resJiLianFu(temp.qualityUnit))
+  temp.dictAge && (temp.dictAge = resJiLianFu(temp.dictAge))
+
+  if (Array.isArray(temp.goods) && temp.goods.length) {
+    for (let i = 0; i < temp.goods.length; i++) {
+      const good = temp.goods[i]
+      good.index = i + 1
+      good.rtf && (good.rtf = JSON.parse(good.rtf).txtArr[0].txt)
+      good.thumb && (good.thumb = await getBase64Sync(baseURL + good.thumb))
+      good.dictAge && (good.dictAge = resJiLianFu(good.dictAge))
+      good.pcsUnit && (good.pcsUnit = resJiLianFu(good.pcsUnit))
+      good.dictTexture3 && (good.dictTexture = resJiLianFu(good.dictTexture3))
+      good.dictTorn && (good.dictTorn = resJiLianFu(good.dictTorn))
+      good.source && (good.source = resJiLianFu(good.source))
+    }
+  } else {
+    temp.goods = []
+  }
+
+  // @ts-ignore
+  if (item.perLine) {
+    // @ts-ignore
+    page = calcTablePages(temp.goods, item)
+  }
+
+  switch (type) {
+    case EXPORT_WORD_ENUM.BORROW:
+      temp = {
+        ...temp,
+        group: numberToChinese(temp.goods.length),
+        num: numberToChinese(
+          temp.goods.reduce((sum: number, item: any) => sum + (item.pcs || 0), 0)
+        ),
+        page: numberToChinese(page)
+      }
+      break
+    case EXPORT_WORD_ENUM.HALL_PUT_BACK:
+      temp = {
+        ...temp,
+        year: date.getFullYear(),
+        group: numberToChinese(temp.goods.length),
+        page: numberToChinese(page)
+      }
+      break
+    case EXPORT_WORD_ENUM.VOUCHER:
+      temp = {
+        ...temp,
+        year: date.getFullYear(),
+        month: date.getMonth() + 1,
+        day: date.getDay(),
+        group: numberToChinese(temp.goods.length),
+        page: numberToChinese(page)
+      }
+      break
+    case EXPORT_WORD_ENUM.FORM_FOR_DIGITAL:
+      temp = {
+        ...temp,
+        year: date.getFullYear(),
+        month: date.getMonth() + 1,
+        day: date.getDay()
+      }
+      break
+    case EXPORT_WORD_ENUM.COLLECTION_CARD:
+      temp.imagePages = await arrangeImages(temp.imagePages)
+      break
+  }
+
+  exportWordDocx(`/templates/${item.templateName}`, temp, item.fileName)
+}

+ 51 - 0
yarn.lock

@@ -2308,6 +2308,11 @@
   resolved "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
+"@types/lodash@^4.17.16":
+  version "4.17.16"
+  resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.16.tgz#94ae78fab4a38d73086e962d0b65c30d816bfb0a"
+  integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==
+
 "@types/mime@*":
   version "3.0.4"
   resolved "https://registry.npmmirror.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
@@ -2730,6 +2735,11 @@
     "@webassemblyjs/ast" "1.11.6"
     "@xtuc/long" "4.2.2"
 
+"@xmldom/xmldom@^0.9.8":
+  version "0.9.8"
+  resolved "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.8.tgz#1471e82bdff9e8f20ee8bbe60d4ffa8a516e78d8"
+  integrity sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==
+
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
   resolved "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@@ -4372,6 +4382,20 @@ doctrine@^3.0.0:
   dependencies:
     esutils "^2.0.2"
 
+docxtemplater-image-module-free@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.npmmirror.com/docxtemplater-image-module-free/-/docxtemplater-image-module-free-1.1.1.tgz#a7f12125c791624759c4d468412f8cf44c539392"
+  integrity sha512-aWOzVQN7ggDYjfoy3pTTNrcrZ7/CJrQcI9cT+hmyHE6nRLR67nt5yPFPe9hm9VWbfYIED2fi+3itOnF0TE/RWQ==
+  dependencies:
+    xmldom "^0.1.27"
+
+docxtemplater@^3.61.1:
+  version "3.61.1"
+  resolved "https://registry.npmmirror.com/docxtemplater/-/docxtemplater-3.61.1.tgz#40c85c63ad9c9eac2c07b26a02e859c35b4d4a88"
+  integrity sha512-4jmFfD4IjrJM8AakMf0ke+KLGPf1aW9Y0HTQSGx9TdrL3h5xQqLh44u7AZjuZH6J4ZfKbyz56RLSnSIqpT/CmQ==
+  dependencies:
+    "@xmldom/xmldom" "^0.9.8"
+
 dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9:
   version "0.5.16"
   resolved "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
@@ -5209,6 +5233,11 @@ file-saver@^1.3.3:
   resolved "https://registry.npmmirror.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8"
   integrity sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==
 
+file-saver@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
+  integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
+
 filelist@^1.0.4:
   version "1.0.4"
   resolved "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5"
@@ -6987,6 +7016,11 @@ jsonpointer@^5.0.0:
     object.assign "^4.1.4"
     object.values "^1.1.6"
 
+jszip-utils@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.npmmirror.com/jszip-utils/-/jszip-utils-0.1.0.tgz#8c04cdedcdb291e83f055f5b261b3a3188ceca0b"
+  integrity sha512-tBNe0o3HAf8vo0BrOYnLPnXNo5A3KsRMnkBFYjh20Y3GPYGfgyoclEMgvVchx0nnL+mherPi74yLPIusHUQpZg==
+
 keyv@^4.5.3:
   version "4.5.4"
   resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -7671,6 +7705,11 @@ p-try@^2.0.0:
   resolved "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
+pako@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
+  integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
+
 param-case@^3.0.4:
   version "3.0.4"
   resolved "https://registry.npmmirror.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
@@ -7794,6 +7833,13 @@ pirates@^4.0.1, pirates@^4.0.4:
   resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
   integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
 
+pizzip@^3.1.8:
+  version "3.1.8"
+  resolved "https://registry.npmmirror.com/pizzip/-/pizzip-3.1.8.tgz#2028944e4845cb08fb79412939fc41d842659858"
+  integrity sha512-Nynadaz6/o1cmXDQGKqmTnbeaY10BGNc1PPOZYJMfLcjWfVJQ1ARngpVO+BrrRl/WUNIj0V9uJNrP72mkfsY5A==
+  dependencies:
+    pako "^2.1.0"
+
 pkg-dir@^4.1.0, pkg-dir@^4.2.0:
   version "4.2.0"
   resolved "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@@ -11097,6 +11143,11 @@ xmlchars@^2.2.0:
   resolved "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
   integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
 
+xmldom@^0.1.27:
+  version "0.1.31"
+  resolved "https://registry.npmmirror.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+
 y18n@^5.0.5:
   version "5.0.8"
   resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"