news 2026/4/23 11:16:29

Vue3/React 结合 pdfjs 实现拖拽盖章签名等操作,支持 PDF多页展示,导出图片与 PDF

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3/React 结合 pdfjs 实现拖拽盖章签名等操作,支持 PDF多页展示,导出图片与 PDF

PDF 拖拽盖章平台

在 AI 能基本实现百分之九十以上的前端代码时,不知道写这种前端工具还有没有人看?

我用相对详细的方式,完整拆解一个「PDF 拖拽盖章平台」的实现过程,覆盖多页渲染、拖拽盖章、撤销/还原、导出图片与 PDF、性能优化(懒渲染)等关键环节。示例包含 React 与 Vue3 两套实现,逻辑一致、写法不同。

目标与约束

目标

  • 支持上传多页 PDF。
  • 在预览区域拖拽印章,支持骑缝章。
  • 支持撤销 / 还原。
  • 支持导出图片和 PDF。
  • 大文件也能流畅渲染,不“卡成 PPT”。

主要约束

  • 浏览器对 canvas 尺寸有上限(不同浏览器略有差异)。
  • 长图导出容易失败,需要降级方案。
  • 大 PDF 一次性渲染会阻塞主线程。

核心思路:统一坐标系 + 多页 canvas

这里的关键是:把整份 PDF 当成一张“虚拟长画布”

  • 每一页各有一个canvas,显示真实页面内容。
  • 所有盖章坐标都以“整份文档坐标系”为准。
  • 每页只要知道自己在整份文档中的位置(pagePositions),就能把盖章正确映射回去。

这样做有两个好处:

  1. 骑缝章天然支持:印章跨页,坐标也能跨页。
  2. 导出更稳定:导出时可自由选择“整图”或“逐页”。
核心依赖
  • pdfjs-dist:解析与渲染 PDF
  • pdf-lib:导出带印章的 PDF(图片型 PDF)

安装示例:

pnpm add pdfjs-dist pdf-lib

PDF 解析与页面尺寸获取

先读取文档并计算每页尺寸。这里只取尺寸,不渲染,避免一开始就卡死。

const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); const pdf = await loadingTask.promise; const pages = []; for (let pageIndex = 1; pageIndex <= pdf.numPages; pageIndex += 1) { const page = await pdf.getPage(pageIndex); const viewport = page.getViewport({ scale: PAGE_SCALE }); pages.push({ width: viewport.width, height: viewport.height }); }

拿到pages后,就能计算整份文档尺寸和每页偏移量。

const docSize = useMemo(() => { const width = Math.max(...pdfPages.map((page) => page.width)); const height = pdfPages.reduce( (sum, page, index) => sum + page.height + (index < pdfPages.length - 1 ? PAGE_GAP : 0), 0 ); return { width, height }; }, [pdfPages]); const pagePositions = useMemo(() => { let offsetY = 0; return pdfPages.map((page, index) => { const pos = { x: (docSize.width - page.width) / 2, y: offsetY }; offsetY += page.height + (index < pdfPages.length - 1 ? PAGE_GAP : 0); return pos; }); }, [pdfPages, docSize.width]);

解释:

  • docSize是整个虚拟画布大小。
  • pagePositions是每页在虚拟画布中的左上角坐标。

预览区滚动与布局

多页 PDF 不可能全部撑开,所以预览区必须做“内部滚动”。

.pdf-stage { max-height: clamp(520px, 70vh, 820px); overflow: auto; }

这样页面滚动只发生在 PDF 区域内,用户体验会舒服很多。

拖拽盖章实现

坐标换算

拖拽时需要把屏幕坐标转换成“文档坐标”。关键点就是overlay的矩形位置。

const rect = overlayRef.current.getBoundingClientRect(); const x = event.clientX - rect.left - template.width / 2; const y = event.clientY - rect.top - template.height / 2; const nextStamp = { instanceId: buildInstanceId(template.id), src: template.src, width: template.width, height: template.height, x: clamp(x, 0, docSize.width - template.width), y: clamp(y, 0, docSize.height - template.height), };
实时拖动 + 撤销栈

拖动过程中只更新“临时状态”,拖动结束再写入历史栈,保证撤销栈干净。

// 实时更新 updateLiveStamps((prev) => prev.map(...)); // 拖动结束写入历史 if (drag.moved) commitStamps(liveStampsRef.current);

好处:撤销时不是“细碎步进”,而是一次拖动一个记录。

性能优化:懒渲染 + 队列

渲染 PDF 是最容易卡顿的地方。解决方案是:

  • IntersectionObserver:只有当页面进入视口时才渲染。
  • 渲染队列:保证渲染顺序,不并发拖慢主线程。
  • 预渲染前两页:首屏更快。
const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const index = Number(entry.target.dataset.index); queueRender(index); }); }, { root: stageElement, rootMargin: '240px 0px', threshold: 0.1 } );

渲染队列逻辑:

const renderPage = async (index) => { const page = await pdfDoc.getPage(index + 1); const viewport = page.getViewport({ scale: PAGE_SCALE }); const canvas = canvasRefs.current[index]; const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: ctx, viewport }).promise; };

这样渲染压力被“分散到用户滚动过程”,不会一次性卡死。

导出图片(长图 + 逐页降级)

导出长图时,浏览器对 canvas 尺寸限制很严格。如果文档太长,直接导出会失败,因此需要检测并降级。

const isTooLarge = docSize.width > MAX_EXPORT_DIMENSION || docSize.height > MAX_EXPORT_DIMENSION || docSize.width * docSize.height > MAX_EXPORT_PIXELS; if (isTooLarge) { // 改为逐页导出 }

逐页导出时,要把全局印章坐标换算到当前页坐标,这样骑缝章也不会丢。

导出 PDF(完整文件)

导出 PDF 用pdf-lib做合成:

  1. 每一页画布(含印章)转为 PNG。
  2. 插入到新 PDF 页。
  3. 生成 PDF 并下载。
const pdfDocument = await PDFDocument.create(); const pngImage = await pdfDocument.embedPng(pngBytes); const pdfPage = pdfDocument.addPage([page.width, page.height]); pdfPage.drawImage(pngImage, { x: 0, y: 0, width: page.width, height: page.height });

下载逻辑:

const pdfBytes = await pdfDocument.save(); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); link.download = `盖章结果-${new Date().toISOString().slice(0, 10)}.pdf`; link.href = url; link.click();

导出的 PDF 为“图片型 PDF”,兼容性高,但文字不可搜索。如果要保留矢量文字,需要更复杂的“原 PDF 叠加”方案。

扩展方向

  1. 矢量 PDF 导出:直接在原 PDF 叠加印章(更复杂,但可保留文字可搜索)。
  2. 通用库封装:提炼核心逻辑为core,React/Vue 只是适配层。
  3. 企业场景扩展:模板库、权限管理、批量盖章。

如果你准备上线到业务系统,建议在此基础上增加:

  • 盖章操作日志
  • 导出前的预检查(页数、尺寸)
  • 失败重试和导出进度提示

这样体验会更接近商业级工具。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 4:43:43

Software Development Process Project Management 2

11. "What is the difference between track and trace? Track Definition: Monitoring the progress, status, or history of an entity 实体 (e.g., changes, issues, or user activities) over time. Usage in Software Development: Version Control: Tracking co…

作者头像 李华
网站建设 2026/4/22 7:14:47

Vue-插槽 (Slot) 的多种高级玩法

前言在组件化开发中&#xff0c;插槽 (Slot) 是实现内容分发&#xff08;Content Distribution&#xff09;的核心机制。它允许我们将组件的“外壳”与“内容”解耦&#xff0c;让组件具备极高的扩展性。一、 什么是插槽&#xff1f;插槽是子组件提供给父组件的 “占位符” &am…

作者头像 李华
网站建设 2026/4/16 19:47:09

网络安全从入门到入狱,2026黑客技术路线图

网络安全从入门到入狱&#xff0c;2026黑客技术路线图 网络安全世界瞬息万变&#xff0c;攻防技术日新月异。2026年&#xff0c;随着AI深度融入、量子计算威胁初显、物联网设备爆炸式增长&#xff0c;以及法规合规要求日益严格&#xff08;如中国的《数据安全法》、《个人信息保…

作者头像 李华
网站建设 2026/4/19 18:43:05

5款AI写论文哪个好?实测避坑!宏智树AI凭真实硬实力封神

作为深耕论文写作科普的教育测评博主&#xff0c;毕业季后台被问爆的问题只有一个&#xff1a;“5款热门AI写论文工具实测&#xff0c;哪款能真正搞定毕业论文&#xff1f;不踩坑、不造假、高效率&#xff1f;”不同于市面上“泛泛而谈”的测评&#xff0c;我花了10天时间&…

作者头像 李华
网站建设 2026/4/3 4:45:54

矢量网络分析仪(VNA)毫米波光

一、内容简介 在光通信链路中增加聚合带宽的愿望并没有减少。多年来&#xff0c;这一增长主要是通过复用额外的光波长&#xff08;或其他光学参数&#xff09;或改变调制技术来实现的&#xff0c;但可能不会增加单载波调制带宽。最近&#xff0c;人们还希望增加这些调制带宽&am…

作者头像 李华
网站建设 2026/4/18 8:02:11

SQL生成报错大模型回答结果不固定(dify)

我个人在dify制作查询数据库得出的心得。废话不多说。SQL生成报错问题&#xff1a;SQL生成会存在一定的报错概率&#xff0c;需要大量的提示词限制才能减少报错的几率&#xff0c;最高可以控制在90%-95%左右&#xff0c;不可能100%的&#xff0c;根本原因就在于“大模型”&…

作者头像 李华