004:多个PDF合并成一个PDF【网页版】

从零实现一个纯前端 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() 接受 ArrayBufferUint8Array。为了保险起见,统一用 Uint8Array 存储,加载时再从存储的副本复制一份新的 Uint8Array

javascript 复制代码
const copyBuf = new Uint8Array(f.bytes);
const srcPdf = await PDFDocument.load(copyBuf);

这样做的好处是,即使 pdf-lib 内部对传入的数据做了任何操作,也不会影响原始存储的数据。

合并流程的设计

整体流程并不复杂:

  1. 用户添加 PDF 文件(点击或拖拽)
  2. 解析每个文件的页数,展示在列表中
  3. 用户通过拖拽调整条目顺序
  4. 点击"合并"按钮,按顺序逐个加载并合并
  5. 生成新 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>
相关推荐
千码君20161 小时前
应用市场V1.0 普通用户 注销账户的业务逻辑
状态模式
咏方舟【长江支流】2 小时前
【前端2】单据编辑 -订单单据主子表,万能模板行,自定义添加行
前端·前端框架·状态模式·咏方舟-长江支流
SamChan902 小时前
用PostgreSQL+pgvector构建PDF翻译记忆库:向量相似度检索+增量更新实战
数据库·python·ai·postgresql·pdf·wpf
涤生大数据11 小时前
一次“没有运行日志”的DolphinScheduler任务失败排查
大数据·数据库·人工智能·状态模式
2601_9639067817 小时前
中文线装古籍电子书排版一键生成工具,PDF导出
pdf·软件需求
FungLeo1 天前
React 管理后台实战 · 列表里突然出现一堆空白行?PIPL 擦除后前端怎么处理才不露馅
前端·react.js·状态模式·pipl
百事牛科技1 天前
只可读不可改!PDF禁止修改实操方法
windows·pdf
fruge1 天前
不用反复折腾 Word 排版:本地部署 Magic Resume,在线编辑并导出 PDF
pdf
国科安芯1 天前
ASL3159S:把精密信号的“破音“挡在切换之外的 0.9Ω 开关
服务器·网络·数据库·架构·状态模式·抗辐射加固