纯前端处理 OFD 格式文件
OFD 是什么
首先我们了解一下 OFD 文件,以下来自 ChatGPT 回答:
OFD(Open Fixed Document)是一种基于中国的开放文档格式,用于表示电子文档和印刷文档。它是中国国家标准之一,主要用于政府、企业和行业之间的文档交换和存档。OFD 文件通常用于电子发票、合同、报告、审批文件等需要长期存档的文件类型
需知
来自公司需求,第一次听到 OFD 文件,常见的格式我相信大家多多少少都处理过,网上的各种处理库也不少,但由于 OFD 是国产文件,可能网上的资源较少,我找了一个还比较流行的库,但是不太符合公司的需求,所以我在库中提取了较为关键的 js 文件,然后自己实现了一遍,当然以下效果也不是我们的需求,我大概简化了一下并做了一些功能
完整代码会贴到最后,文章中只说下逻辑,样式和模板不多说啥,复制下来看一遍就懂了,我代码中写的注释超他吗清楚和详细,很多废话注释,看不惯的不看哈
之前伪造的发票文件违规了... 现在下方展示全是空白页,不会影响理解逻辑和功能
相关技术或库
Scss:用来编写基本样式,使用 Vs Code 插件 编译
Vue:用不用都行,不想使用 Vue 的自己看着代码改,我注释写的很清除
Print:Print.js 库,处理文件打印,为了简化需求,使用库,逻辑中基本没有什么打印逻辑
Ofd:从某个库提炼出的关键 js 文件,库名应该就叫 ofd.js
Gsap:用来做页面滚动,例如分页时定位到指定位置等等...
需要相关文件和插件的请私信联系我
功能点图例
这里可以点开大图看一下我标注的说明,对后面代码理解有帮助
效果 - 导航目录显示和隐藏
点击导航中的目录按钮,可切换左侧目录的显示和隐藏
效果 - 下载
点击下载按钮触发下载
效果 - 打印
点击打印按钮触发打印
效果 - 缩放
100% - 200%,js 中有配置项,想要更大或者更小的缩放比例,可以自己看着代码调试,当时做到最大 200%,是因为这个插件渲染出来的 Dom 好像最大就只能是一个固定尺寸,反正对于我的需求足够了,这里还有个防抖哈,不想要的自己移除,原因是如果渲染的 ofd 文件页面过多,会卡顿,每次改变应该都要重新渲染的,所以设置了防抖,防止频繁调整时重新渲染
效果 - 分页
点击后可以导航到指定页
效果 - 目录
显示页码和当前页高亮,可以点击目录项导航到指定页
效果 - 预览
最终预览区域,滚动到对应位置,会同步目录的高亮还有分页页码
实现逻辑
数据区,你不需要太在意这里,只是如果下面哪个地方使用到了,你能理解
data: { // 后端文件地址 oldfileUrl: 'http://192.168.1.126:3000/增值税电子普通发票.ofd', // 文件名称 filename: 'filename.ofd', // 渲染结果 domsResult: null, // 渲染加载中 renderLoading: false, // 当前页数索引 pagesindex: 0, // 总页数 totalpages: 1, // 当前缩放比例 | 1 - 2 scaling: 1.5, // 左侧目录是否隐藏 catalogueHide: false, // 缩放防抖计时器 renderTimer: null, // 滚动时长 scrollDuration: 500, // 当前预览滚动是否为控制滚动 previewScrollControl: false, // 恢复预览滚动监听计时器 previewScrollWatchTimer: null }
1. 将后端文件地址转为 Blob,如果你是前端直接通过用户选择来获取 File 的,可以在代码中跳过这一步
ofdFileLoad() { // 渲染开始 this.renderLoading = true // 解析 Url let parsedUrl = new URL(this.oldfileUrl) // 从路径中提取文件名 let fileName = decodeURIComponent(parsedUrl.pathname.split('/').pop()) // 文件读取 fetch(this.oldfileUrl) // 成功 | 转为 Blob .then(response => response.blob()) // 成功 | 调用 ofdFileBlob .then(blob => this.ofdFileBlob(blob, fileName)) // 失败 .catch(error => console.error('File fetch error:', error)) }
2. 处理为 Blob 之后,转为 File 对象,使用 ofd 库来处理,这里 ofd 库处理后,会得到一个对象,里面有一些信息,还有它转换成功后的 Dom,也就是咱们要渲染的数据
ofdFileBlob(blob, fileName) { // 将 Blob 转为 File 对象 let file = new File([blob], fileName, { type: blob.type }) // 赋值名称 this.filename = fileName // 使用 Ofd 处理 File 内容 ofd.parseOfdDocument({ ofd: file, fail: error => console.log(error), success: result => { this.domsResult = result[0] this.renderDocument() } }) }
3. 处理完成,调用对应的渲染函数,并使用 requestAnimationFrame 来回调 Dom 加载完成,因为这个渲染的时间还是不短的,我们可以等待 Dom 渲染完成后再处理其他操作
renderDocument() { // 赋值目录页码 this.totalpages = this.domsResult.pages.length // 调用 renderCatalogue,渲染目录 this.renderCatalogue() // 调用 renderPreview,渲染目录 this.renderPreview() // 等待 Dom 渲染完成 requestAnimationFrame(() => this.renderFinish()) }
4. 接着在 renderCatalogue 和 renderPreview 里根据 ofdFileBlob 函数中获取到的 Dom 信息,分别渲染目录和预览
// 渲染目录函数 renderCatalogue() { // 目录 let catalogueList = this.$refs.catalogueList // 目录渲染器 let catalogueListRender = ofd.renderOfd(catalogueList.offsetWidth, this.domsResult) // 遍历目录渲染器 catalogueListRender.forEach((item, index) => { // 创建 Li 元素 let li = document.createElement('LI') // 创建 Div 元素 let div = document.createElement('DIV') // 创建 Span 元素 let span = document.createElement('SPAN') // 给创建的 Li 元素添加类 li.classList.add('item') // 如果这是第一个 li,添加选中 if (index == 0) li.classList.add('pitch') // 给创建的 Div 元素添加类 div.classList.add('item-ofd') // 添加 Item 到 div div.appendChild(item) // 给创建的 Span 元素添加类 span.classList.add('item-page') // 添加页码到 Span span.innerHTML = index + 1 // 将 Div 添加到 li li.appendChild(div) // 将 Span 添加到 li li.appendChild(span) // 将 Li 添加到 catalogueList catalogueList.appendChild(li) }) }, // 渲染预览函数 renderPreview() { // 预览元素 let preview = this.$refs.preview // 预览渲染器,最大尺寸为 1050 ? (存疑) let previewRender = ofd.renderOfd(this.scaling * 525, this.domsResult) // 清空预览元素内容 preview.innerHTML = '' // 遍历预览渲染器 previewRender.forEach((item, index) => { // 创建 Div 元素 let div = document.createElement('DIV') // 给创建的 Div 元素添加类 div.classList.add('item-ofd') // 给创建的 Div 元素添加样式 div.style.paddingTop = this.scaling * 40 + 'px' // 最后一个 Div 元素设置其他样式 if (index + 1 == previewRender.length) div.style.paddingBottom = this.scaling * 40 + 'px' // 添加 Item 到 div div.appendChild(item) // 添加 item 到 displayArea preview.appendChild(div) }) }
5. 接着,我们在渲染完成的 renderFinish 函数中处理操作事件
renderFinish() { // 渲染完成 this.renderLoading = false // 目录显示切换按钮点击 this.$refs.catalogueButton.addEventListener('click', ev => this.catalogueHide = !this.catalogueHide) // 打印按钮点击 this.$refs.printButton.addEventListener('click', ev => printJS({printable:'previewPrint',type:'html'})) // 下载按钮点击 this.$refs.downloadButton.addEventListener('click', ev => this.$refs.fileDownload.click()) // 缩小按钮点击 this.$refs.decreaseButton.addEventListener('click', ev => this.adjustScaling('decrease')) // 放大按钮点击 this.$refs.increaseButton.addEventListener('click', ev => this.adjustScaling('increase')) // 上页按钮点击 this.$refs.prevButton.addEventListener('click', ev => this.switchPages(this.pagesindex - 1, 'click-button')) // 下页按钮点击 this.$refs.nextButton.addEventListener('click', ev => this.switchPages(this.pagesindex + 1, 'click-button')) // 监听左侧目录项点击 this.$refs.catalogueList.querySelectorAll('.item').forEach((item, index) => { item.addEventListener('click', event => { this.switchPages(index, 'click-catalogue') }) }) // 调用事件,监听预览区域的可见元素变化 this.watchPreviewVisibleChange() }
6. 缩放操作函数
adjustScaling(type) { // 缩小 if (type == 'decrease' && this.scaling > 1) this.scaling -= 0.1 // 放大 if (type == 'increase' && this.scaling { // 重新渲染 this.renderPreview() // 重新监听预览区域的可见元素变化 this.watchPreviewVisibleChange() }, 400) }
7. 监听预览区域的可见元素变化,也就是同步目录高亮和分页的关键代码
watchPreviewVisibleChange() { // 所有元素 let sign = this.$refs.preview.querySelectorAll('.item-ofd') // 创建 Intersection Observer 对象 let observer = new IntersectionObserver(entries => { // 循环处理 entries.forEach(entry => { // 元素是否可见 if (entry.isIntersecting) { // 如果没有标记, 再触发 if (!entry.target.hasAttribute('data-triggered')) { // 获取当前可见的元素索引 let index = Array.from(sign).indexOf(entry.target) // 向元素追加标记, 确保每次进入视口时只触发一次 entry.target.setAttribute('data-triggered', 'true') // 触发 this.switchPages(index, 'scroll-preview') } } // 元素不可见 else{ // 移除元素追加的标记, 使下次进入时可以触发 entry.target.removeAttribute('data-triggered') } }) // 设置阈值 }, { threshold: 0.5 } ) // 循环调用 sign.forEach(item => observer.observe(item)) }
8. 切换页码函数,例如滚动预览部分时、点击上下页按钮时,点击左侧目录时的操作
switchPages(index, type) { // 停止操作,当前页码不符合切换条件 if (index (this.totalpages - 1)) { return } // 赋值页码 this.pagesindex = index // 切换目录选中 this.changeCataloguePitch(index) // 触发类型是 click-button 或者 click-catalogue if (type == 'click-button' || type == 'click-catalogue') { // 当前预览滚动为控制滚动 this.previewScrollControl = true // 清除先前的计时器,避免状态混乱 clearTimeout(this.previewScrollWatchTimer) // 设置计时器,恢复 previewScrollControl 状态 setTimeout(() => this.previewScrollControl = false, this.scrollDuration) } // 触发类型是 click-button if (type == 'click-button') { // 滚动目录 this.scrollCatalogue(index) // 滚动预览 this.scrollPreviewVw(index) } // 触发类型是 click-catalogue if (type == 'click-catalogue') { // 滚动预览 this.scrollPreviewVw(index) } // 触发类型是 scroll-preview,并且 this.previewScrollControl 为 false if (type == 'scroll-preview' && !this.previewScrollControl) { // 滚动目录 this.scrollCatalogue(index) } }
9. 切换目录的选中函数
changeCataloguePitch(index) { // 目录项 let catalogueItem = this.$refs.catalogue.querySelectorAll('.list .item') // 移除目录项所有的选中 catalogueItem.forEach(item => item.classList.remove('pitch')) // 选中当前的目录项 catalogueItem[index].classList.add('pitch') }
10. 控制目录滚动 和 控制预览滚动
// 控制目录滚动 scrollCatalogue(index) { // 目录 let catalogue = this.$refs.catalogue // 目录项 let catalogueItem = this.$refs.catalogue.querySelectorAll('.list .item') // 目录位置 let catalogueRect = catalogue.getBoundingClientRect() // 目录项位置 let nowItemRect = catalogueItem[index].getBoundingClientRect() // 使用 gsap 滚动 gsap.to(catalogue, { scrollTo: { y: nowItemRect.top - catalogueRect.top + catalogue.scrollTop, autoKill: true }, duration: this.scrollDuration / 1000 }) }, // 控制预览滚动 scrollPreviewVw(index) { // 预览 let preview = this.$refs.preview // 预览项 let previewItem = this.$refs.preview.querySelectorAll('.item-ofd') // 预览位置 let previewRect = preview.getBoundingClientRect() // 预览项位置 let nowItemRect = previewItem[index].getBoundingClientRect() // 使用 gsap 滚动 gsap.to(preview, { scrollTo: { y: nowItemRect.top - previewRect.top + preview.scrollTop + 1, autoKill: true }, duration: this.scrollDuration / 1000 }) }
完整代码
Analysis ofd {{ filename }} {{ pagesindex + 1 }} / {{ totalpages }} {{ (scaling * 100).toFixed() }} % Loading new Vue({ el: '.container', data: { // 后端文件地址 oldfileUrl: 'http://192.168.1.126:3000/增值税电子普通发票.ofd', // 文件名称 filename: 'filename.ofd', // 渲染结果 domsResult: null, // 渲染加载中 renderLoading: false, // 当前页数索引 pagesindex: 0, // 总页数 totalpages: 1, // 当前缩放比例 | 1 - 2 scaling: 1.5, // 左侧目录是否隐藏 catalogueHide: false, // 缩放防抖计时器 renderTimer: null, // 滚动时长 scrollDuration: 500, // 当前预览滚动是否为控制滚动 previewScrollControl: false, // 恢复预览滚动监听计时器 previewScrollWatchTimer: null }, methods: { // 加载 OFD 文件 ofdFileLoad() { // 渲染开始 this.renderLoading = true // 解析 Url let parsedUrl = new URL(this.oldfileUrl) // 从路径中提取文件名 let fileName = decodeURIComponent(parsedUrl.pathname.split('/').pop()) // 文件读取 fetch(this.oldfileUrl) // 成功 | 转为 Blob .then(response => response.blob()) // 成功 | 调用 ofdFileBlob .then(blob => this.ofdFileBlob(blob, fileName)) // 失败 .catch(error => console.error('File fetch error:', error)) }, // OFD 加载成功,处理得到的 Blob ofdFileBlob(blob, fileName) { // 将 Blob 转为 File 对象 let file = new File([blob], fileName, { type: blob.type }) // 赋值名称 this.filename = fileName // 使用 Ofd 处理 File 内容 ofd.parseOfdDocument({ ofd: file, fail: error => console.log(error), success: result => { this.domsResult = result[0] this.renderDocument() } }) }, // Blob 处理完成,开始渲染元素 renderDocument() { // 赋值目录页码 this.totalpages = this.domsResult.pages.length // 调用 renderCatalogue,渲染目录 this.renderCatalogue() // 调用 renderPreview,渲染目录 this.renderPreview() // 等待 Dom 渲染完成 requestAnimationFrame(() => this.renderFinish()) }, // 渲染目录函数 renderCatalogue() { // 目录 let catalogueList = this.$refs.catalogueList // 目录渲染器 let catalogueListRender = ofd.renderOfd(catalogueList.offsetWidth, this.domsResult) // 遍历目录渲染器 catalogueListRender.forEach((item, index) => { // 创建 Li 元素 let li = document.createElement('LI') // 创建 Div 元素 let div = document.createElement('DIV') // 创建 Span 元素 let span = document.createElement('SPAN') // 给创建的 Li 元素添加类 li.classList.add('item') // 如果这是第一个 li,添加选中 if (index == 0) li.classList.add('pitch') // 给创建的 Div 元素添加类 div.classList.add('item-ofd') // 添加 Item 到 div div.appendChild(item) // 给创建的 Span 元素添加类 span.classList.add('item-page') // 添加页码到 Span span.innerHTML = index + 1 // 将 Div 添加到 li li.appendChild(div) // 将 Span 添加到 li li.appendChild(span) // 将 Li 添加到 catalogueList catalogueList.appendChild(li) }) }, // 渲染预览函数 renderPreview() { // 预览元素 let preview = this.$refs.preview // 预览渲染器,最大尺寸为 1050 ? (存疑) let previewRender = ofd.renderOfd(this.scaling * 525, this.domsResult) // 清空预览元素内容 preview.innerHTML = '' // 遍历预览渲染器 previewRender.forEach((item, index) => { // 创建 Div 元素 let div = document.createElement('DIV') // 给创建的 Div 元素添加类 div.classList.add('item-ofd') // 给创建的 Div 元素添加样式 div.style.paddingTop = this.scaling * 40 + 'px' // 最后一个 Div 元素设置其他样式 if (index + 1 == previewRender.length) div.style.paddingBottom = this.scaling * 40 + 'px' // 添加 Item 到 div div.appendChild(item) // 添加 item 到 displayArea preview.appendChild(div) }) }, // 渲染完成 renderFinish() { // 渲染完成 this.renderLoading = false // 目录显示切换按钮点击 this.$refs.catalogueButton.addEventListener('click', ev => this.catalogueHide = !this.catalogueHide) // 打印按钮点击 this.$refs.printButton.addEventListener('click', ev => printJS({printable:'previewPrint',type:'html'})) // 下载按钮点击 this.$refs.downloadButton.addEventListener('click', ev => this.$refs.fileDownload.click()) // 缩小按钮点击 this.$refs.decreaseButton.addEventListener('click', ev => this.adjustScaling('decrease')) // 放大按钮点击 this.$refs.increaseButton.addEventListener('click', ev => this.adjustScaling('increase')) // 上页按钮点击 this.$refs.prevButton.addEventListener('click', ev => this.switchPages(this.pagesindex - 1, 'click-button')) // 下页按钮点击 this.$refs.nextButton.addEventListener('click', ev => this.switchPages(this.pagesindex + 1, 'click-button')) // 监听左侧目录项点击 this.$refs.catalogueList.querySelectorAll('.item').forEach((item, index) => { item.addEventListener('click', event => { this.switchPages(index, 'click-catalogue') }) }) // 调用事件,监听预览区域的可见元素变化 this.watchPreviewVisibleChange() }, // 调整缩放 adjustScaling(type) { // 缩小 if (type == 'decrease' && this.scaling > 1) this.scaling -= 0.1 // 放大 if (type == 'increase' && this.scaling { // 重新渲染 this.renderPreview() // 重新监听预览区域的可见元素变化 this.watchPreviewVisibleChange() }, 400) }, // 监听预览区域的可见元素变化 watchPreviewVisibleChange() { // 所有元素 let sign = this.$refs.preview.querySelectorAll('.item-ofd') // 创建 Intersection Observer 对象 let observer = new IntersectionObserver(entries => { // 循环处理 entries.forEach(entry => { // 元素是否可见 if (entry.isIntersecting) { // 如果没有标记, 再触发 if (!entry.target.hasAttribute('data-triggered')) { // 获取当前可见的元素索引 let index = Array.from(sign).indexOf(entry.target) // 向元素追加标记, 确保每次进入视口时只触发一次 entry.target.setAttribute('data-triggered', 'true') // 触发 this.switchPages(index, 'scroll-preview') } } // 元素不可见 else{ // 移除元素追加的标记, 使下次进入时可以触发 entry.target.removeAttribute('data-triggered') } }) // 设置阈值 }, { threshold: 0.5 } ) // 循环调用 sign.forEach(item => observer.observe(item)) }, // 切换页码 switchPages(index, type) { // 停止操作,当前页码不符合切换条件 if (index (this.totalpages - 1)) { return } // 赋值页码 this.pagesindex = index // 切换目录选中 this.changeCataloguePitch(index) // 触发类型是 click-button 或者 click-catalogue if (type == 'click-button' || type == 'click-catalogue') { // 当前预览滚动为控制滚动 this.previewScrollControl = true // 清除先前的计时器,避免状态混乱 clearTimeout(this.previewScrollWatchTimer) // 设置计时器,恢复 previewScrollControl 状态 setTimeout(() => this.previewScrollControl = false, this.scrollDuration) } // 触发类型是 click-button if (type == 'click-button') { // 滚动目录 this.scrollCatalogue(index) // 滚动预览 this.scrollPreviewVw(index) } // 触发类型是 click-catalogue if (type == 'click-catalogue') { // 滚动预览 this.scrollPreviewVw(index) } // 触发类型是 scroll-preview,并且 this.previewScrollControl 为 false if (type == 'scroll-preview' && !this.previewScrollControl) { // 滚动目录 this.scrollCatalogue(index) } }, // 切换目录选中 changeCataloguePitch(index) { // 目录项 let catalogueItem = this.$refs.catalogue.querySelectorAll('.list .item') // 移除目录项所有的选中 catalogueItem.forEach(item => item.classList.remove('pitch')) // 选中当前的目录项 catalogueItem[index].classList.add('pitch') }, // 控制目录滚动 scrollCatalogue(index) { // 目录 let catalogue = this.$refs.catalogue // 目录项 let catalogueItem = this.$refs.catalogue.querySelectorAll('.list .item') // 目录位置 let catalogueRect = catalogue.getBoundingClientRect() // 目录项位置 let nowItemRect = catalogueItem[index].getBoundingClientRect() // 使用 gsap 滚动 gsap.to(catalogue, { scrollTo: { y: nowItemRect.top - catalogueRect.top + catalogue.scrollTop, autoKill: true }, duration: this.scrollDuration / 1000 }) }, // 控制预览滚动 scrollPreviewVw(index) { // 预览 let preview = this.$refs.preview // 预览项 let previewItem = this.$refs.preview.querySelectorAll('.item-ofd') // 预览位置 let previewRect = preview.getBoundingClientRect() // 预览项位置 let nowItemRect = previewItem[index].getBoundingClientRect() // 使用 gsap 滚动 gsap.to(preview, { scrollTo: { y: nowItemRect.top - previewRect.top + preview.scrollTop + 1, autoKill: true }, duration: this.scrollDuration / 1000 }) } }, mounted() { this.ofdFileLoad() } })
样式(scss)
*{ margin: 0px; padding: 0px; box-sizing: border-box; font-family: PingFang SC; user-select: none; list-style: none; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #383d48; cursor: pointer; } html{ width: 100vw; height: 100vh; overflow: hidden; } .container{ width: 100vw; height: 100vh; color: #FFFFFF; background: #0D1117; position: relative; .navigation{ width: 100%; height: 60px; background: #23272F; border-bottom: 1px solid #484F58; display: flex; align-items: center; padding: 0px 24px; white-space: nowrap; .icons{ width: 40px; height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 4px; transition: ease .3s; } .icons:hover{ background: #383d48; } .filename{ font-size: 16px; margin-left: 8px; } .printButton{ margin-right: 14px; } .toolbar{ flex: 1; display: flex; align-items: center; justify-content: center; .deco{ height: 24px; width: 1px; background: #484F58; margin: 0px 24px; } .chunk{ display: flex; align-items: center; .texts{ background: #383d48; font-size: 14px; height: 30px; display: flex; align-items: center; padding: 0px 14px; margin: 0px 10px; border-radius: 4px; } } } } .containers{ width: 100vw; height: calc(100vh - 60px); display: flex; .catalogue{ width: 240px; height: 100%; background: #23272F; border-right: 1px solid #484F58; overflow-x: hidden; overflow-y: auto; transition: ease .3s; .list{ display: flex; align-items: center; flex-direction: column; margin: 0px 25px 35px 25px; .item{ display: flex; flex-direction: column; align-items: center; padding-top: 35px; .item-ofd{ cursor: pointer; opacity: .5; border-radius: 2px; border: 5px solid transparent; transition: ease .5s; overflow: hidden; } .item-page{ margin-top: 10px; } } .item:hover{ .item-ofd{ opacity: 0.7; } } .pitch{ .item-ofd{ cursor: default; border: 5px solid #9682E2; opacity: 1; } } .pitch:hover{ .item-ofd{ opacity: 1; } } } } .catalogueHide{ width: 0px; } .preview{ flex: 1; overflow: auto; .item-ofd{ display: flex; justify-content: center; } } } .renderLoading{ width: 100vw; height: 100vh; position: absolute; top: 0px; left: 0px; background: rgba($color: #000000, $alpha: .5); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); color: #FFFFFF; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 16px; span{ margin-top: 10px; font-family: Consolas, PingFang SC; } } }