从零实现一个纯前端 PDF 合并工具:踩坑与方案
本地处理、拖拽排序、零上传 ------ 一个轻量级 PDF 合并器的技术实现笔记
为什么需要这样一个工具
日常工作中,合并 PDF 是个高频需求。市面上大多数工具要么收费,要么需要上传文件到云端,对于涉及敏感内容的文档,总归不太放心。
于是我想着做一个完全本地的 PDF 合并工具:所有文件在浏览器内存中处理,不上传任何数据到服务器,同时支持拖拽调整页面顺序,操作直观。
最终效果是一个单 HTML 文件,打开即用,依赖三个 CDN 库,没有后端。
技术选型思路
前端处理 PDF,绕不开两个库:
- pdf-lib:用于创建、修改、合并 PDF 文档,纯 JavaScript 实现,不依赖原生模块。优点是 API 简洁,缺点是体积稍大,且对某些特殊字体支持有限。
- pdf.js :Mozilla 维护的 PDF 渲染引擎,这里只用来读取 PDF 的页数信息,因为 pdf-lib 本身不提供直接读取页数的接口(虽然可以通过
getPageCount获取,但在某些场景下需要预解析)。
拖拽排序直接用了 SortableJS,稳定且轻量,处理列表拖拽的交互细节非常省心。
核心实现:两个容易被忽略的坑
1. ArrayBuffer 的 detach 问题
这是开发过程中踩得最实的坑。
File.arrayBuffer() 返回的是一个 ArrayBuffer。当传入 pdfjsLib.getDocument() 时,pdf.js 内部会对这个 ArrayBuffer 进行"转移"(transfer),导致原 buffer 被 detach,后续无法再次使用。
也就是说,同一个 ArrayBuffer 不能既用来读取页数,又用来存储原始数据供后续合并使用。
解决办法很简单:在读文件时,立即复制两份独立的副本。
javascript
const arrayBuf = await file.arrayBuffer();
const readBuffer = arrayBuf.slice(0); // 副本1:给 pdf.js 读取页数
const storageBuffer = arrayBuf.slice(0); // 副本2:存下来给 pdf-lib 合并用
slice(0) 会生成一个全新的 ArrayBuffer,彼此独立,互不影响。存储时保留 Uint8Array 格式,方便后续传给 PDFDocument.load()。
2. pdf-lib 加载字节数据的格式
PDFDocument.load() 接受 ArrayBuffer 或 Uint8Array。为了保险起见,统一用 Uint8Array 存储,加载时再从存储的副本复制一份新的 Uint8Array:
javascript
const copyBuf = new Uint8Array(f.bytes);
const srcPdf = await PDFDocument.load(copyBuf);
这样做的好处是,即使 pdf-lib 内部对传入的数据做了任何操作,也不会影响原始存储的数据。
合并流程的设计
整体流程并不复杂:
- 用户添加 PDF 文件(点击或拖拽)
- 解析每个文件的页数,展示在列表中
- 用户通过拖拽调整条目顺序
- 点击"合并"按钮,按顺序逐个加载并合并
- 生成新 PDF 并下载
javascript
const outPdf = await PDFDocument.create();
for (const f of pdfFiles) {
const copyBuf = new Uint8Array(f.bytes);
const srcPdf = await PDFDocument.load(copyBuf);
const pages = await outPdf.copyPages(srcPdf, srcPdf.getPageIndices());
pages.forEach(p => outPdf.addPage(p));
}
const pdfBytes = await outPdf.save();
这里需要注意的是 copyPages 方法:它返回的是页面对象的数组,需要手动 addPage 才能将页面真正添加到目标文档中。
关于 UI 交互的几个细节
- 拖拽区域同时支持点击上传和拖拽上传,符合用户习惯
- 每个 PDF 条目显示文件名和页数,信息一目了然
- 删除按钮和清空列表提供了灵活的文件管理方式
- 合并过程中显示 loading 状态,防止用户重复操作
拖拽排序使用 SortableJS 的 handle 选项,只允许拖拽手柄触发排序,避免误触其他按钮区域。
javascript
new Sortable(pdfListEl, {
animation: 150,
handle: '.drag-handle',
onEnd(evt) {
const item = pdfFiles.splice(evt.oldIndex, 1)[0];
pdfFiles.splice(evt.newIndex, 0, item);
}
});
排序结束后同步更新数据数组,保持 UI 与数据一致。
性能与限制
这个工具适合处理中小规模的 PDF 合并(几十个文件,每个文件几十页以内)。如果文件数量太多或单个文件过大,浏览器的内存压力会明显增加,毕竟所有文件数据都保存在内存中。
对于日常办公场景,这个量级通常够用了。如果确实需要处理大批量文件,可以考虑分批次合并。
一些可以继续优化的方向
- 进度提示:合并大量文件时,可以显示当前处理到第几个文件
- 页面级操作:支持展开查看每个 PDF 的页面列表,并单独调整页面顺序
- 错误恢复:某个文件解析失败时,给出明确提示并跳过,不影响其他文件的合并
- Worker 处理:将 PDF 解析和合并放到 Web Worker 中,避免阻塞主线程
效果预览


完整代码
如下:
bash
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF合并工具|拖拽调整顺序</title>
<script src="https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style>
* {box-sizing: border-box;margin:0;padding:0;font-family:system-ui, sans-serif;}
body{background:#f5f7fa;padding:24px;max-width:900px;margin:0 auto;color:#222;}
h1{font-size:24px;margin-bottom:20px;text-align:center;}
.upload-area{border:2px dashed #4080ff;border-radius:12px;padding:40px;text-align:center;background:#f0f6ff;margin-bottom:20px;cursor:pointer;transition:0.2s;}
.upload-area:hover{background:#e4edff;}
.upload-area.drag-over{background:#d4e2ff;border-color:#2563eb;}
#fileInput{display:none;}
.pdf-list{background:#fff;border-radius:12px;padding:16px;min-height:120px;margin-bottom:20px;box-shadow:0 1px 4px #00000015;}
.pdf-item{display:flex;align-items:center;padding:12px;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:8px;gap:12px;background:#fafafa;}
.drag-handle{cursor:grab;color:#888;font-size:20px;user-select:none;}
.pdf-item-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.pdf-item-page{color:#666;font-size:13px;}
.pdf-item-del{border:none;background:#ef4444;color:#fff;padding:4px 10px;border-radius:6px;cursor:pointer;}
.btn-wrap{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;}
button{padding:12px 24px;border:none;border-radius:8px;font-size:15px;cursor:pointer;}
#mergeBtn{background:#2563eb;color:#fff;}
#clearBtn{background:#e5e7eb;color:#333;}
.tip{margin-top:16px;text-align:center;color:#666;font-size:13px;}
.loading{text-align:center;padding:20px;color:#2563eb;display:none;}
</style>
</head>
<body>
<h1>PDF合并工具(拖拽调整顺序)</h1>
<div class="upload-area" id="uploadArea">
<p>点击或者拖拽PDF文件到此处</p>
<p style="font-size:13px;color:#666;margin-top:8px;">本地处理,文件不会上传网络</p>
<input type="file" id="fileInput" accept=".pdf" multiple>
</div>
<div class="pdf-list" id="pdfList"></div>
<div class="loading" id="loading">处理中,请稍候...</div>
<div class="btn-wrap">
<button id="mergeBtn">合并并下载PDF</button>
<button id="clearBtn">清空列表</button>
</div>
<p class="tip">拖拽条目调整合并顺序,顺序从上到下为最终PDF页码顺序</p>
<script>
const { PDFDocument } = PDFLib;
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const pdfListEl = document.getElementById('pdfList');
const mergeBtn = document.getElementById('mergeBtn');
const clearBtn = document.getElementById('clearBtn');
const loadingEl = document.getElementById('loading');
let pdfFiles = []; // {name, bytes:Uint8Array, pageCount}
// 拖拽列表初始化
const sortable = new Sortable(pdfListEl, {
animation:150,
handle:'.drag-handle',
onEnd(evt){
const item = pdfFiles.splice(evt.oldIndex,1)[0];
pdfFiles.splice(evt.newIndex,0,item);
}
});
uploadArea.addEventListener('click',()=>fileInput.click());
uploadArea.addEventListener('dragover',e=>{e.preventDefault();uploadArea.classList.add('drag-over');});
uploadArea.addEventListener('dragleave',()=>uploadArea.classList.remove('drag-over'));
uploadArea.addEventListener('drop',async e=>{
e.preventDefault();
uploadArea.classList.remove('drag-over');
await handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change',async ()=>{
await handleFiles(fileInput.files);
fileInput.value='';
});
// ========== 🔧 关键修复:使用 slice(0) 复制 ArrayBuffer ==========
async function handleFiles(fileList){
loadingEl.style.display='block';
for(let file of fileList){
if(file.type!=='application/pdf') continue;
const arrayBuf = await file.arrayBuffer();
// 复制两份完全独立的 ArrayBuffer
const readBuffer = arrayBuf.slice(0);
const storageBuffer = arrayBuf.slice(0);
const readCopy = new Uint8Array(readBuffer);
const storageCopy = new Uint8Array(storageBuffer);
const pdfDoc = await pdfjsLib.getDocument(readCopy).promise;
pdfFiles.push({
name: file.name,
bytes: storageCopy, // 独立副本,永远不会被 detach
pageCount: pdfDoc.numPages
});
}
renderList();
loadingEl.style.display='none';
}
function renderList(){
pdfListEl.innerHTML='';
pdfFiles.forEach((item,idx)=>{
const div = document.createElement('div');
div.className='pdf-item';
div.innerHTML=`
<span class="drag-handle">⋮⋮</span>
<div class="pdf-item-name">${item.name}</div>
<span class="pdf-item-page">${item.pageCount}页</span>
<button class="pdf-item-del" data-idx="${idx}">删除</button>
`;
pdfListEl.appendChild(div);
})
document.querySelectorAll('.pdf-item-del').forEach(btn=>{
btn.onclick=e=>{
const i = Number(e.target.dataset.idx);
pdfFiles.splice(i,1);
renderList();
}
})
}
mergeBtn.onclick = async ()=>{
if(pdfFiles.length===0){alert('请先添加PDF文件');return;}
loadingEl.style.display='block';
try{
const outPdf = await PDFDocument.create();
for(const f of pdfFiles){
// 从存储的副本再复制一份用于加载,确保万无一失
const copyBuf = new Uint8Array(f.bytes);
const srcPdf = await PDFDocument.load(copyBuf);
const pages = await outPdf.copyPages(srcPdf, srcPdf.getPageIndices());
pages.forEach(p=>outPdf.addPage(p));
}
const pdfBytes = await outPdf.save();
downloadFile(pdfBytes,'合并输出.pdf');
}catch(err){
alert('合并失败:'+err.message);
console.error(err);
}finally{
loadingEl.style.display='none';
}
}
function downloadFile(uint8,filename){
const blob = new Blob([uint8],{type:'application/pdf'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
clearBtn.onclick = ()=>{
pdfFiles=[];
renderList();
}
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js';
</script>
</body>
</html>
