当前位置: 首页 > news >正文

PDF 拆分怎么弄 | 选页/范围/单页/均分四种模式完整教程

在线体验:geekformat.com/zh-CN/pdf/split

PDF 拆分看起来像一个功能,真正做起来其实至少有四种完全不同的需求:

  • 自由选页:点选几页,导出成一份新 PDF
  • 范围拆分:输入页码范围,按分组拆出去
  • 单页导出:每页单独导成一个 PDF 并打包 ZIP
  • 均分打包:把整份 PDF 平均拆成几份并打包下载

这篇文章直接按完整实现链路来拆,把一套能上线的 PDF 拆分工具讲清楚。

整体执行流程

flowchart TBA["上传 PDF"] --> B["pdf.js 读取总页数"]B --> C["逐页生成缩略图"]C --> D["选择拆分模式"]D --> E["自由选页 / 范围输入 / 每页导出 / 均分份数"]E --> F["开始拆分"]F --> G["pdf-lib 复制目标页"]G --> H["单文件结果 or 多文件结果"]H --> I["单 PDF 直接下载"]H --> J["JSZip 打包 ZIP 下载"]

这个工具最大的难点,不是导出,而是怎么让这四种模式共用同一个工作区。

一、模式先收紧成四种

type SplitMode = 'free' | 'range' | 'single' | 'chunks'interface SplitResult {type: 'single' | 'zip'url?: stringzipUrl?: stringfileName?: stringfileCount?: numbersizeLabel?: string
}const [pdfFile, setPdfFile] = useState<File | null>(null)
const [thumbnails, setThumbnails] = useState<(string | null)[]>([])
const [pageCount, setPageCount] = useState(0)
const [mode, setMode] = useState<SplitMode>('free')
const [selectedPages, setSelectedPages] = useState<Set<number>>(new Set())
const [highlightedPages, setHighlightedPages] = useState<Set<number>>(new Set())

关键点有两个:

  • selectedPages 用于自由选页
  • highlightedPages 用于范围模式和均分模式的视觉提示

二、PDF 页码范围 / 均分拆分:核心工具函数

范围解析

function parseRanges(input: string, max: number): number[][] {const segments = input.split(',').map((item) => item.trim()).filter(Boolean)const result: number[][] = []for (const segment of segments) {const match = segment.match(/^(\d+)(?:-(\d+))?$/)if (!match) continueconst from = parseInt(match[1], 10)const to = match[2] ? parseInt(match[2], 10) : fromif (from < 1 || to > max || from > to) continueconst pages: number[] = []for (let page = from; page <= to; page += 1) {pages.push(page)}result.push(pages)}return result
}

均分计算

function calcChunks(total: number, count: number) {const result: { from: number; to: number; count: number }[] = []const base = Math.floor(total / count)const extra = total % countlet start = 1for (let index = 0; index < count; index += 1) {const size = base + (index < extra ? 1 : 0)result.push({ from: start, to: start + size - 1, count: size })start += size}return result
}

三、上传后立即生成缩略图

PDF 拆分如果没有缩略图,用户很容易选错页。这里上传之后直接跑一遍 pdf.js,把每页预览先做出来:

const loadPdf = useCallback(async (file: File) => {const lib = (window as { pdfjsLib: PdfjsLib }).pdfjsLibconst buffer = await file.arrayBuffer()const doc = await lib.getDocument({ data: buffer }).promiseconst total = doc.numPagessetPageCount(total)setThumbnails(Array(total).fill(null))setSelectedPages(new Set(Array.from({ length: total }, (_, index) => index + 1)))for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {const page = await doc.getPage(pageNumber)const viewport = page.getViewport({ scale: 0.35 })const canvas = document.createElement('canvas')canvas.width = viewport.widthcanvas.height = viewport.heightconst context = canvas.getContext('2d')if (!context) continueawait page.render({ canvasContext: context, viewport }).promiseconst url = canvas.toDataURL('image/jpeg', 0.7)setThumbnails((prev) => {const next = [...prev]next[pageNumber - 1] = urlreturn next})}
}, [])

四、模式切换时,高亮页要自动联动

范围模式和均分模式的价值,除了导出,更重要的是用户能先看见"哪些页会被拆出去"。

useEffect(() => {if (mode === 'range') {const groups = parseRanges(rangeInput, pageCount)setHighlightedPages(new Set(groups.flat()))} else if (mode === 'chunks') {setHighlightedPages(new Set(Array.from({ length: pageCount }, (_, index) => index + 1)),)} else {setHighlightedPages(new Set())}
}, [mode, rangeInput, pageCount])

五、真正的拆分逻辑,统一在一个 handler 里

const handleSplit = useCallback(async () => {if (!pdfFile) returnconst { PDFDocument } = await import('pdf-lib')setProcessing(true)try {if (mode === 'free') {const pages = Array.from(selectedPages).sort((a, b) => a - b)if (pages.length === 0) returnconst bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)const out = await PDFDocument.create()for (const pageNumber of pages) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()const blob = new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })setResult({ type: 'single', url: URL.createObjectURL(blob), fileName: 'split.pdf' })} else if (mode === 'range') {const groups = parseRanges(rangeInput, pageCount)if (groups.length === 0) returnconst bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)if (groups.length === 1) {const out = await PDFDocument.create()for (const pageNumber of groups[0]) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()const blob = new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })setResult({ type: 'single', url: URL.createObjectURL(blob), fileName: 'range_split.pdf' })} else {const JSZip = (await import('jszip')).defaultconst zip = new JSZip()for (let index = 0; index < groups.length; index += 1) {const out = await PDFDocument.create()for (const pageNumber of groups[index]) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()zip.file(`part_${index + 1}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: groups.length })}} else if (mode === 'single') {const JSZip = (await import('jszip')).defaultconst zip = new JSZip()const bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {const out = await PDFDocument.create()const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)const pdfBytes = await out.save()zip.file(`page_${String(pageNumber).padStart(3, '0')}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: pageCount })} else if (mode === 'chunks') {const chunkList = calcChunks(pageCount, chunks)const JSZip = (await import('jszip')).defaultconst zip = new JSZip()const bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)for (let index = 0; index < chunkList.length; index += 1) {const { from, to } = chunkList[index]const out = await PDFDocument.create()for (let pageNumber = from; pageNumber <= to; pageNumber += 1) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()zip.file(`part_${index + 1}_p${from}-${to}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: chunkList.length })}} finally {setProcessing(false)}
}, [pdfFile, mode, selectedPages, rangeInput, pageCount, chunks])

六、自由选页模式:Set 状态管理

自由模式里,用户会不断点选和取消点选。这里直接用 Set<number>,状态切换非常干净:

const togglePage = useCallback((pageNumber: number) => {if (mode !== 'free') returnsetSelectedPages((prev) => {const next = new Set(prev)if (next.has(pageNumber)) next.delete(pageNumber)else next.add(pageNumber)return next})
}, [mode])

七、为什么多结果导出一定要接 JSZip

只要进入下面两类场景:

  • 每页单独导出
  • 一次拆出多份 PDF

就不适合一个个触发浏览器下载了。最稳妥的处理就是打包成 ZIP 一次下载。

也就是说:

  • 单结果输出,用 Blob URL
  • 多结果输出,用 JSZip

八、常见踩坑总结

只有导出,没有可视化缩略图

页数一多,用户很容易选错。

范围模式只校验输入,不做高亮反馈

用户无法确认最终拆分范围。

每页拆分还强行逐个下载

浏览器会连弹很多次下载,体验很差。

四种模式各写一套页面

逻辑重复,维护成本会迅速上涨。

常见问题 FAQ

PDF 拆分后清晰度会变差吗?

不会。拆分只是复制页面,不涉及任何重新编码,原 PDF 清晰度完整保留。

拆分过程会上传文件到服务器吗?

不会。所有操作在浏览器本地完成,文件不会上传到任何服务器。

可以指定页码范围拆分吗?

可以。使用范围模式,输入 1-3,5,8-10 这样的格式即可拆分成多个 PDF。

均分模式会把 PDF 平均分成几份?

可以。将 PDF 平均拆成 N 份,每份页数尽可能均衡,适合需要分发材料的场景。

单页导出会打包成 ZIP 吗?

是的。每页单独导成一个 PDF 文件,全部打包成 ZIP 一次下载,避免多次点击。

支持多少页的 PDF 拆分?

建议单次操作控制在 200 页以内,以获得最佳性能。


PDF 拆分的关键,不是会不会 copyPages,而是能不能把"预览、选页、模式切换、单结果下载、批量 ZIP 下载"统一成一条稳定链路。

只要你把这条链路搭顺,四种拆分模式完全可以在一个工作区里自然共存。

http://www.rkmt.cn/news/1528908.html

相关文章:

  • 嘉兴市奥克斯空调维修师傅电话|各区金牌师傅,靠谱选欧米到家 - 欧米到家
  • VLC点击暂停插件:终极播放控制体验完全指南
  • 2026更新定西市本地人必选的瓷砖空鼓专业维修公司TOP5推荐!卫生间空鼓翘边,厨房空鼓翘边,客厅空鼓翘边,全天响应,免费上门,6月专业瓷砖空鼓修复公司持证上岗师傅排名最新深度调研方案) - 一休咨询
  • 2026更新福州市本地人必选的瓷砖空鼓专业维修公司TOP5推荐!卫生间空鼓翘边,厨房空鼓翘边,客厅空鼓翘边,全天响应,免费上门,6月专业瓷砖空鼓修复公司持证上岗师傅排名最新深度调研方案) - 一休咨询
  • 【CANdelaStudio-从入门到深入到实战】19 会话切换的安全门禁:27服务与状态机深度联动
  • 深入解析LINFlexD控制器:LIN总线在汽车电子中的核心配置与实战
  • 小学期第五周
  • 【趣解】DNS:域名到IP地址的“翻译官“
  • 静心 - Karry
  • 本地生活推广计划拆分:24小时底价推广的操作框架
  • python FastAPI 最小服务
  • 交互准则
  • 051、TensorFlow Lite for Microcontrollers官方示例解析
  • AUTOSAR架构之通信服务
  • 金蝶k3 erp 与 免费生产排程软件isuperaps 数据集成指南
  • 神经网络字母识别Matlab程序带GUI11112(设计源文件+万字报告+讲解)(支持资料、图片参考_降重降ai)
  • 异地工作搬家不用自己送货!家具行李分类线上预约,上门取件轻松跨城搬迁 - 时讯资讯
  • 新手避坑指南:在ZedBoard上给AD9361写Verilog配置代码,这几个细节千万别忽略
  • 怎么寄大件物流便宜?大件物流怎么寄最省钱?2026年寄大件便宜方法全攻略 - 快递物流资讯
  • S32K344 eMIOS实战避坑:用MCAL配置PWM时,Counter Bus选错通道的后果
  • 如何评估下属工作量是否饱和
  • 6月15日最新邀请码
  • 避开UDS 0x87服务的那些‘坑’:从NRC 0x22/0x24错误码反推正确使用姿势
  • 地铁延误预测新范式:基于多源症状的边缘实时预警
  • SAP新系统上线避坑指南:统一日记账分类账配置一致性检查(FINS_CUST_CONS_CHK事务码详解)
  • 构建企业级质量保障体系:RePKG项目的自动化测试架构设计与实施
  • Windows 11/10 搭建LabelImg标注环境避坑全记录:从Anaconda配置到解决点击闪退
  • 题解:AtCoder AT_awc0006_d Placement of Security Guards
  • 小学期第五周学习笔记
  • UniApp微信登录从开发到上线:我踩过的5个坑和最佳实践